UNPKG

typia

Version:

Superfast runtime validators with only one line

618 lines (617 loc) • 27.2 kB
import { FileSystemIdentity } from "./FileSystemIdentity.mjs"; import fs from "fs"; import path from "path"; import { createCommand } from "commander"; import inquirer from "inquirer"; import { createRequire } from "module"; import os from "os"; import { glob, isDynamicPattern } from "tinyglobby"; //#region src/executable/TypiaGenerateWizard.ts let TypiaGenerateWizard; (function(_TypiaGenerateWizard) { async function generate() { console.log("----------------------------------------"); console.log(" Typia Generate Wizard"); console.log("----------------------------------------"); await build(await parseArguments()); } _TypiaGenerateWizard.generate = generate; async function parseArguments() { const command = createCommand("typia generate"); command.usage("[options] [files...]"); command.argument("[files...]", "input TypeScript source files or globs"); command.option("--input <path>", "input directory"); command.option("--output <directory>", "output directory"); command.option("--project <project>", "tsconfig.json/jsconfig.json file or directory"); const questioned = { value: false }; const prompt = inquirer.createPromptModule; const input = (name) => async (message) => { questioned.value = true; return (await prompt()({ type: "input", name, message, default: "" }))[name]; }; const configure = async () => { const file = findProjectConfigFile(process.cwd()); if (file === null) throw new URIError(`Unable to find "tsconfig.json" or "jsconfig.json" file.`); return file; }; return new Promise((resolve, reject) => { command.action(async (files, options) => { try { if (files.length !== 0 && options.input !== void 0) throw new URIError("Error on TypiaGenerateWizard.generate(): file arguments cannot be combined with --input."); if (files.length === 0) options.input ??= await input("input")("input directory"); if (files.length !== 0 && options.output === void 0) throw new URIError("Error on TypiaGenerateWizard.generate(): output directory is required when file arguments are used."); const output = options.output ?? await input("output")("output directory"); const project = options.project ?? await configure(); if (questioned.value) console.log(""); resolve({ input: options.input, output, project, files }); } catch (exp) { reject(exp); } }); command.parseAsync(process.argv.slice(3), { from: "user" }).catch(reject); }); } async function build(location) { location.output = path.resolve(location.output); location.project = resolveProjectConfigFile(location.project); const policy = new FileSystemIdentity.Policy(); const outputProbe = await nearestExistingAncestor(location.output); await ensureExistingDirectoryPath({ label: "output parent path", directory: outputProbe }); policy.observe(await FileSystemIdentity.probeDirectory(outputProbe), outputProbe); policy.observe(await FileSystemIdentity.inspectDirectory(path.dirname(location.project)), path.dirname(location.project)); const entries = location.files.length === 0 ? await prepareDirectoryInput(location, policy) : await prepareFileInputs(location, policy); const identity = policy.get(); await inspectTargetDirectories({ identity, output: location.output, targets: entries.map((entry) => entry.target) }); const binary = resolveTsgoBinary(); const cwd = path.dirname(location.project); const temporaryProject = await createTemporaryProject({ entries, project: location.project }); let transformed; try { transformed = transformProject({ binary, cwd, projectRoot: cwd, tsconfig: temporaryProject.config }); } finally { await fs.promises.rm(temporaryProject.directory, { force: true, recursive: true }); } const outputByKey = indexTransformedOutputs(transformed, identity); const outputs = entries.map((entry) => { const output = getTransformedOutput({ cwd, entry, identity, outputByKey }); if (output === void 0) throw new URIError(`Error on TypiaGenerateWizard.generate(): no transformed output for ${entry.file}. Check that --project includes the file.`); return { entry, output }; }); await ensureOutputDirectory(location.output); await ensureTargetDirectories({ identity, output: location.output, targets: outputs.map(({ entry }) => entry.target) }); await ensurePhysicalTargets({ identity, output: location.output, entries: outputs.map(({ entry }) => entry) }); await ensureTargetFiles(outputs.map(({ entry }) => entry), identity); for (const { entry, output } of outputs) await fs.promises.writeFile(entry.target, formatOutput(output), "utf8"); } async function createTemporaryProject(props) { const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "typia-generate-project-")); const config = path.join(directory, "tsconfig.json"); try { await fs.promises.writeFile(config, JSON.stringify({ extends: props.project, exclude: [], files: props.entries.map((entry) => compilerInputPath(entry.file)), include: [] }), "utf8"); return { config, directory }; } catch (error) { await fs.promises.rm(directory, { force: true, recursive: true }); throw new URIError(`Error on TypiaGenerateWizard.generate(): unable to prepare the bounded input project: ${formatUnknownError(error)}`); } } async function ensureOutputDirectory(output) { if (fs.existsSync(output) === false) { await ensureCreatableDirectory(output); await fs.promises.mkdir(output, { recursive: true }); } else await ensureExistingDirectory({ label: "output path", directory: output }); } async function ensureTargetDirectories(props) { await inspectTargetDirectories(props); const directories = targetDirectories(props); for (const directory of directories.values()) { try { await fs.promises.mkdir(directory, { recursive: true }); } catch (exp) { throw new URIError(`Error on TypiaGenerateWizard.generate(): unable to create output parent directory ${directory}: ${formatUnknownError(exp)}`); } await ensureExistingDirectory({ label: "output parent path", directory }); } } async function inspectTargetDirectories(props) { const directories = targetDirectories(props); for (const directory of directories.values()) { await ensureOutputAncestorDirectories({ identity: props.identity, output: props.output, directory }); if (fs.existsSync(directory)) await ensureExistingDirectory({ label: "output parent path", directory }); } } function targetDirectories(props) { const directories = /* @__PURE__ */ new Map(); for (const target of props.targets) { const directory = path.dirname(target); directories.set(props.identity.filesystemKey(directory), directory); } return directories; } async function ensureCreatableDirectory(directory) { await ensureExistingDirectoryPath({ label: "output parent path", directory: await nearestExistingAncestor(directory) }); } async function nearestExistingAncestor(directory) { let current = path.resolve(directory); while (fs.existsSync(current) === false) { const parent = path.dirname(current); if (parent === current) throw new URIError(`Error on TypiaGenerateWizard.generate(): unable to find existing output parent path: ${directory}`); current = parent; } return current; } async function ensureOutputAncestorDirectories(props) { const output = path.resolve(props.output); const directory = path.resolve(props.directory); if (props.identity.contains(directory, output) === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): output parent path escapes output directory: ${props.directory}`); const relative = path.relative(output, directory); if (relative === "") return; let current = output; for (const segment of relative.split(path.sep)) { current = path.join(current, segment); let stat; try { stat = await fs.promises.lstat(current); } catch (exp) { if (isMissingFileError(exp)) return; throw new URIError(`Error on TypiaGenerateWizard.generate(): unable to inspect output parent path ${current}: ${formatUnknownError(exp)}`); } if (stat.isSymbolicLink()) throw new URIError(`Error on TypiaGenerateWizard.generate(): output parent path contains a symbolic link: ${current}`); if (stat.isDirectory() === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): output parent path is not a directory: ${current}`); } } async function ensureExistingDirectory(props) { await ensureExistingDirectoryPath(props); } async function ensureExistingDirectoryPath(props) { const directory = path.resolve(props.directory); const parsed = path.parse(directory); const relative = path.relative(parsed.root, directory); let current = parsed.root; for (const segment of relative === "" ? [] : relative.split(path.sep)) { current = path.join(current, segment); await ensureExistingDirectorySegment({ label: path.normalize(current) === path.normalize(directory) ? props.label : `${props.label} ancestor`, directory: current }); } } async function ensureExistingDirectorySegment(props) { const stat = await fs.promises.lstat(props.directory); if (stat.isSymbolicLink()) throw new URIError(`Error on TypiaGenerateWizard.generate(): ${props.label} is a symbolic link: ${props.directory}`); if (stat.isDirectory() === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): ${props.label} is not a directory: ${props.directory}`); } async function ensurePhysicalTargets(props) { const output = await fs.promises.realpath(props.output); const inputs = /* @__PURE__ */ new Set(); for (const entry of props.entries) inputs.add(props.identity.filesystemKey(await fs.promises.realpath(entry.file))); for (const entry of props.entries) { const parent = path.dirname(entry.target); const directory = await fs.promises.realpath(parent); if (props.identity.contains(directory, output) === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): output parent path escapes output directory through a symbolic link: ${parent}`); const target = path.join(directory, path.basename(entry.target)); if (inputs.has(props.identity.filesystemKey(target))) throw new URIError(`Error on TypiaGenerateWizard.generate(): output file would overwrite input file through a symbolic link: ${entry.target}`); } } async function ensureTargetFiles(entries, identity) { const inputs = /* @__PURE__ */ new Set(); const files = /* @__PURE__ */ new Map(); for (const entry of entries) { inputs.add(fileIdentityKey(await fs.promises.stat(entry.file, { bigint: true }), await fs.promises.realpath(entry.file))); files.set(identity.filesystemKey(entry.target), entry); } for (const entry of files.values()) { let stat; try { stat = await fs.promises.lstat(entry.target, { bigint: true }); } catch (exp) { if (isMissingFileError(exp)) continue; throw new URIError(`Error on TypiaGenerateWizard.generate(): unable to inspect output file ${entry.target}: ${formatUnknownError(exp)}`); } if (stat.isFile() === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): output file path is not a regular file: ${entry.target}`); if (inputs.has(fileIdentityKey(stat, await fs.promises.realpath(entry.target)))) throw new URIError(`Error on TypiaGenerateWizard.generate(): output file would overwrite input file through a physical file alias: ${entry.target}`); if (stat.nlink > BigInt(1)) throw new URIError(`Error on TypiaGenerateWizard.generate(): output file has multiple hard links: ${entry.target}`); } } async function prepareDirectoryInput(location, policy) { if (location.input === void 0) throw new URIError("Error on TypiaGenerateWizard.generate(): input path is required."); const input = path.resolve(location.input); if (fs.existsSync(input) === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): input path does not exist: ${input}`); if (await isDirectory(input) === false) throw new URIError("Error on TypiaGenerateWizard.generate(): input path is not a directory."); const inputReal = await fs.promises.realpath(input); const outputReal = await optionalRealPath(location.output); const files = []; await gather({ container: files, from: input, inputReal, outputReal, policy, visitedDirectories: /* @__PURE__ */ new Set(), visitedFiles: /* @__PURE__ */ new Set() }); return files.map((file) => ({ file, target: path.join(location.output, path.relative(input, file)) })); } async function prepareFileInputs(location, policy) { const targets = /* @__PURE__ */ new Set(); const output = []; for (const input of await expandFileInputs(location.files, location.output, policy)) { const file = path.resolve(input); policy.observe(await FileSystemIdentity.inspectDirectory(path.dirname(file)), path.dirname(file)); const identity = policy.get(); if (fs.existsSync(file) === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): input file does not exist: ${input}`); else if (await isFile(file) === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): input path is not a file: ${input}`); else if (identity.isDeclarationFile(file)) continue; else if (identity.isSupportedExtension(file) === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): input file is not a supported TypeScript source: ${input}`); const target = path.join(location.output, path.basename(file)); if (identity.isSamePath(file, target)) throw new URIError(`Error on TypiaGenerateWizard.generate(): output file would overwrite input file: ${input}`); const key = identity.filesystemKey(target); if (targets.has(key)) throw new URIError(`Error on TypiaGenerateWizard.generate(): duplicate output filename for ${target}`); targets.add(key); output.push({ file, target }); } if (output.length === 0) throw new URIError("Error on TypiaGenerateWizard.generate(): input files do not include any supported TypeScript source files outside the output directory."); return output; } async function expandFileInputs(inputs, directory, policy) { const output = []; for (const input of inputs) { const pattern = toGlobPattern(input); if (isDynamicPattern(pattern, { caseSensitiveMatch: true })) { const searchDirectory = await globSearchDirectory(input); const caseSensitive = await FileSystemIdentity.inspectDirectory(searchDirectory); if (caseSensitive === void 0) throw new URIError(`Error on TypiaGenerateWizard.generate(): unable to determine filesystem case behavior for input pattern base ${searchDirectory}.`); policy.observe(caseSensitive, searchDirectory); const identity = policy.get(); const matches = await glob(pattern, { absolute: true, caseSensitiveMatch: identity.caseSensitive, cwd: process.cwd(), onlyFiles: true }); if (matches.length === 0) throw new URIError(`Error on TypiaGenerateWizard.generate(): input pattern does not match any files: ${input}`); output.push(...excludeOutputFiles(matches, directory, identity).filter((file) => identity.isSupportedExtension(file))); } else { const file = path.resolve(input); policy.observe(await FileSystemIdentity.inspectDirectory(path.dirname(file)), path.dirname(file)); if (policy.get().contains(file, directory) === false) output.push(file); } } return output; } function excludeOutputFiles(files, directory, identity) { return files.filter((file) => identity.contains(file, directory) === false); } async function globSearchDirectory(input) { let current = path.resolve(input); while (isDynamicPattern(toGlobPattern(current), { caseSensitiveMatch: true })) { const parent = path.dirname(current); if (parent === current) break; current = parent; } if (fs.existsSync(current) && await isDirectory(current)) return current; return nearestExistingAncestor(path.dirname(current)); } function toGlobPattern(input) { return input.replace(/\\/g, "/"); } function transformProject(props) { const result = new (loadTtscCompiler())({ binary: props.binary, cwd: props.cwd, projectRoot: props.projectRoot, tsconfig: props.tsconfig }).transform(); if (result.type === "success") return result.typescript; if (result.type === "failure") throw new URIError(`Error on TypiaGenerateWizard.generate(): ${formatDiagnostics(result.diagnostics)}`); throw new URIError(`Error on TypiaGenerateWizard.generate(): ${formatUnknownError(result.error)}`); } function resolveProjectConfigFile(project) { const resolved = path.resolve(project); if (fs.existsSync(resolved) === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): project path does not exist: ${resolved}`); const stat = fs.statSync(resolved); if (stat.isDirectory()) { for (const filename of ["tsconfig.json", "jsconfig.json"]) { const candidate = path.join(resolved, filename); if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return resolveRealPath(candidate); } throw new URIError(`Error on TypiaGenerateWizard.generate(): project directory has no tsconfig.json or jsconfig.json: ${resolved}`); } if (stat.isFile() === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): project path is not a file: ${resolved}`); return resolveRealPath(resolved); } function findProjectConfigFile(directory) { let current = path.resolve(directory); while (true) { for (const filename of ["tsconfig.json", "jsconfig.json"]) { const candidate = path.join(current, filename); if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return resolveRealPath(candidate); } const parent = path.dirname(current); if (parent === current) return null; current = parent; } } function loadTtscCompiler() { const resolved = resolveFromRoots("ttsc", resolveRuntimeRoots(resolveTypiaPackageRoot())); if (resolved === null) throw new URIError(`Error on TypiaGenerateWizard.generate(): unable to resolve ttsc from the current project, typia package, or workspace root. Run "npm i -D ttsc typescript" before.`); return createRequire(resolved)(resolved).TtscCompiler; } function resolveTsgoBinary() { const explicit = process.env.TTSC_TSGO_BINARY; if (explicit !== void 0 && explicit.length !== 0) { if (path.isAbsolute(explicit) && fs.existsSync(explicit)) return explicit; throw new URIError(`Error on TypiaGenerateWizard.generate(): TTSC_TSGO_BINARY must be an existing absolute path: ${explicit}`); } const manifest = resolveFromRoots("typescript/package.json", resolveRuntimeRoots(resolveTypiaPackageRoot())); if (manifest === null) throw new URIError("Error on TypiaGenerateWizard.generate(): unable to resolve typescript from the current project, typia package, or workspace root."); const platform = `@typescript/typescript-${process.platform}-${process.arch}`; const platformManifest = createRequire(manifest).resolve(`${platform}/package.json`); const binary = path.join(path.dirname(platformManifest), "lib", process.platform === "win32" ? "tsc.exe" : "tsc"); if (fs.existsSync(binary) === false) throw new URIError(`Error on TypiaGenerateWizard.generate(): TypeScript-Go executable not found: ${binary}`); return binary; } function resolveTypiaPackageRoot() { const current = path.dirname(path.resolve(process.argv[1] ?? "")); for (const directory of [path.resolve(current, "..", ".."), path.resolve(current, "..")]) { const file = path.join(directory, "package.json"); if (fs.existsSync(file) === false) continue; try { if (JSON.parse(fs.readFileSync(file, "utf8")).name === "typia") return directory; } catch { continue; } } const resolved = resolveFromRoots("typia/package.json", [process.cwd(), current]); if (resolved === null) throw new URIError("Error on TypiaGenerateWizard.generate(): unable to resolve typia package root."); return path.dirname(resolved); } function resolveRuntimeRoots(packageRoot) { return [ process.cwd(), packageRoot, path.resolve(packageRoot, "..", "..") ]; } function resolveFromRoots(request, roots) { for (const root of roots) try { return createRequire(path.join(root, "package.json")).resolve(request); } catch { continue; } return null; } async function isDirectory(current) { return (await fs.promises.stat(current)).isDirectory(); } async function isFile(current) { return (await fs.promises.stat(current)).isFile(); } async function gather(props) { const currentReal = await resolveTraversalPath(props.from); if (props.outputReal !== void 0 && isPhysicalSameOrChildPath(currentReal, props.outputReal)) return; ensurePhysicalInputContainment({ file: props.from, input: props.inputReal, real: currentReal }); const directoryIdentity = fileIdentityKey(await fs.promises.stat(props.from, { bigint: true }), currentReal); if (props.visitedDirectories.has(directoryIdentity)) { if ((await fs.promises.lstat(props.from)).isSymbolicLink()) throw new URIError(`Error on TypiaGenerateWizard.generate(): input directory link revisits a physical directory: ${props.from}.`); return; } props.visitedDirectories.add(directoryIdentity); props.policy.observe(await FileSystemIdentity.inspectDirectory(props.from), props.from); const identity = props.policy.get(); const entries = await Promise.all((await fs.promises.readdir(props.from)).map(async (name) => { const file = path.join(props.from, name); try { return { file, name, stat: await fs.promises.lstat(file) }; } catch (error) { throw new URIError(`Error on TypiaGenerateWizard.generate(): unable to inspect input path ${file}: ${formatUnknownError(error)}`); } })); entries.sort((x, y) => { const linkOrder = Number(x.stat.isSymbolicLink()) - Number(y.stat.isSymbolicLink()); return linkOrder !== 0 ? linkOrder : Buffer.compare(Buffer.from(x.name), Buffer.from(y.name)); }); for (const entry of entries) { let stat; let real; try { stat = await fs.promises.stat(entry.file, { bigint: true }); real = await fs.promises.realpath(entry.file); } catch (error) { throw new URIError(`Error on TypiaGenerateWizard.generate(): input link target is missing or unreadable: ${entry.file}: ${formatUnknownError(error)}`); } if (props.outputReal !== void 0 && isPhysicalSameOrChildPath(real, props.outputReal)) continue; ensurePhysicalInputContainment({ file: entry.file, input: props.inputReal, real }); if (stat.isDirectory()) { await gather({ ...props, from: entry.file }); continue; } if (stat.isFile() === false || identity.isSupportedExtension(entry.name) === false) continue; const fileIdentity = fileIdentityKey(stat, real); if (props.visitedFiles.has(fileIdentity)) continue; props.visitedFiles.add(fileIdentity); props.container.push(entry.file); } } function formatOutput(output) { return output.startsWith("// @ts-nocheck") ? output : `// @ts-nocheck\n${output}`; } function indexTransformedOutputs(outputs, identity) { const map = /* @__PURE__ */ new Map(); for (const [file, output] of Object.entries(outputs)) { const key = identity.projectFileKey(file); if (map.has(key)) throw new URIError(`Error on TypiaGenerateWizard.generate(): transformed outputs have ambiguous filesystem identities: ${file}.`); map.set(key, output); } return map; } function getTransformedOutput(props) { const output = props.outputByKey.get(props.identity.projectFileKey(projectKey(props.cwd, props.entry.file))); if (output !== void 0) return output; const compilerFile = compilerInputPath(props.entry.file); if (props.identity.isSamePath(compilerFile, props.entry.file) === false && props.identity.contains(compilerFile, props.cwd)) { const compiled = props.outputByKey.get(props.identity.projectFileKey(projectKey(props.cwd, compilerFile))); if (compiled !== void 0) return compiled; } const real = resolveRealPath(props.entry.file); if (props.identity.isSamePath(real, props.entry.file) || props.identity.contains(real, props.cwd) === false) return; return props.outputByKey.get(props.identity.projectFileKey(projectKey(props.cwd, real))); } function projectKey(root, file) { return path.relative(root, file).replace(/\\/g, "/"); } function resolveRealPath(file) { try { return fs.realpathSync(file); } catch { return file; } } function compilerInputPath(file) { try { if (fs.lstatSync(file).isSymbolicLink()) return path.join(resolveRealPath(path.dirname(file)), path.basename(file)); } catch { return file; } return resolveRealPath(file); } async function optionalRealPath(file) { try { return await fs.promises.realpath(file); } catch (error) { if (isMissingFileError(error)) return void 0; throw new URIError(`Error on TypiaGenerateWizard.generate(): unable to resolve path ${file}: ${formatUnknownError(error)}`); } } async function resolveTraversalPath(file) { try { return await fs.promises.realpath(file); } catch (error) { throw new URIError(`Error on TypiaGenerateWizard.generate(): unable to resolve input path ${file}: ${formatUnknownError(error)}`); } } function ensurePhysicalInputContainment(props) { if (isPhysicalSameOrChildPath(props.real, props.input)) return; throw new URIError(`Error on TypiaGenerateWizard.generate(): input path resolves outside the input directory: ${props.file}.`); } function isPhysicalSameOrChildPath(file, directory) { const relative = path.relative(directory, file); return relative === "" || relative !== ".." && relative.startsWith(`..${path.sep}`) === false && path.isAbsolute(relative) === false; } function isMissingFileError(exp) { return typeof exp === "object" && exp !== null && "code" in exp && exp.code === "ENOENT"; } /** * Delegates to {@link FileSystemIdentity.identityKey}, which owns the rule and * carries the reasoning for reading the identity as a `bigint`. */ function fileIdentityKey(stat, realpath) { return FileSystemIdentity.identityKey(stat, realpath); } function formatDiagnostics(diagnostics) { return diagnostics.length === 0 ? "transformation failed" : diagnostics.map((diag) => [ diag.file ?? "ttsc", diag.line === void 0 ? void 0 : `${diag.line}:${diag.character ?? 1}`, diag.messageText ].filter((part) => part !== void 0 && part !== "").join(": ")).join("\n"); } function formatUnknownError(error) { if (error instanceof Error) return error.message; if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") return error.message; return String(error); } })(TypiaGenerateWizard || (TypiaGenerateWizard = {})); //#endregion export { TypiaGenerateWizard }; //# sourceMappingURL=TypiaGenerateWizard.mjs.map