@knighted/duel
Version:
TypeScript dual packages.
493 lines (492 loc) • 19.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.runExportsValidationBlock = exports.maybeLinkNodeModules = exports.exitOnDiagnostics = exports.processDiagnosticsForFile = exports.filterDualPackageDiagnostics = exports.hazardPackageFromMessage = exports.registerCleanupHandlers = exports.createTempCleanup = exports.ensureDotSlash = exports.stripKnownExt = exports.generateExports = exports.getSubpath = exports.readExportsConfig = exports.getCompileFiles = exports.getRealPathAsFileUrl = exports.logWarn = exports.logSuccess = exports.logError = exports.log = void 0;
const node_url_1 = require("node:url");
const promises_1 = require("node:fs/promises");
const node_fs_1 = require("node:fs");
const node_child_process_1 = require("node:child_process");
const node_process_1 = require("node:process");
const node_path_1 = require("node:path");
const find_up_1 = require("find-up");
const COLORS = {
reset: '\x1b[0m',
info: '\x1b[36m',
success: '\x1b[32m',
warn: '\x1b[33m',
error: '\x1b[31m',
};
const log = (msg = '', level = 'info', opts = {}) => {
const { bare = false } = opts;
const palette = {
info: COLORS.info,
success: COLORS.success,
warn: COLORS.warn,
error: COLORS.error,
};
const badge = {
success: '[✓]',
warn: '[!]',
error: '[x]',
info: '[i]',
}[level];
const color = palette[level] ?? COLORS.info;
const prefix = !bare && badge ? `${badge} ` : '';
// eslint-disable-next-line no-console
console.log(`${color}${prefix}%s${COLORS.reset}`, msg);
};
exports.log = log;
const logSuccess = msg => log(msg, 'success');
exports.logSuccess = logSuccess;
const logWarn = msg => log(msg, 'warn');
exports.logWarn = logWarn;
const logError = msg => log(msg, 'error');
exports.logError = logError;
const createTempCleanup = ({ subDir, keepTemp = false, logWarnFn = logWarn }) => {
let cleaned = false;
let cleanupPromise = null;
// Marks cleanup as started; returns false when already running or completed.
const beginCleanup = () => {
if (cleaned)
return false;
cleaned = true;
return true;
};
const cleanupTempSync = () => {
if (!beginCleanup())
return;
if (keepTemp) {
logWarnFn(`DUEL_KEEP_TEMP=1 set; temp workspace preserved at ${subDir}`);
return;
}
try {
(0, node_fs_1.rmSync)(subDir, { force: true, recursive: true });
}
catch {
/* ignore */
}
};
const cleanupTemp = async () => {
if (cleanupPromise)
return cleanupPromise;
const runCleanup = async () => {
if (!beginCleanup())
return;
if (keepTemp) {
logWarnFn(`DUEL_KEEP_TEMP=1 set; temp workspace preserved at ${subDir}`);
return;
}
try {
await (0, promises_1.rm)(subDir, { force: true, recursive: true });
}
catch {
/* ignore */
}
};
cleanupPromise = runCleanup();
return cleanupPromise;
};
return { cleanupTempSync, cleanupTemp };
};
exports.createTempCleanup = createTempCleanup;
const registerCleanupHandlers = cleanupTempSync => {
const onExit = () => cleanupTempSync();
const onSigint = () => {
cleanupTempSync();
process.exit(1);
};
const onSigterm = () => {
cleanupTempSync();
process.exit(1);
};
const onUncaught = err => {
cleanupTempSync();
throw err;
};
const onUnhandled = reason => {
cleanupTempSync();
throw reason instanceof Error ? reason : new Error(String(reason));
};
process.once('exit', onExit);
process.once('SIGINT', onSigint);
process.once('SIGTERM', onSigterm);
process.once('uncaughtException', onUncaught);
process.once('unhandledRejection', onUnhandled);
return () => {
process.removeListener('exit', onExit);
process.removeListener('SIGINT', onSigint);
process.removeListener('SIGTERM', onSigterm);
process.removeListener('uncaughtException', onUncaught);
process.removeListener('unhandledRejection', onUnhandled);
};
};
exports.registerCleanupHandlers = registerCleanupHandlers;
const getRealPathAsFileUrl = async (path) => {
const realPath = await (0, promises_1.realpath)(path);
const asFileUrl = (0, node_url_1.pathToFileURL)(realPath).href;
return asFileUrl;
};
exports.getRealPathAsFileUrl = getRealPathAsFileUrl;
// Backward compatibility: `options` may be a string (cwd) or an object with { cwd, project }.
const getCompileFiles = (tscPath, options = {}) => {
const { cwd: workingDir = (0, node_process_1.cwd)(), project = null } = typeof options === 'string' ? { cwd: options, project: null } : options;
const args = [tscPath];
if (project) {
const projectPath = (0, node_path_1.isAbsolute)(project) ? project : (0, node_path_1.resolve)(workingDir, project);
if (!(0, node_fs_1.existsSync)(projectPath)) {
throw new Error(`Project path not found: ${project}`);
}
args.push('-p', project);
}
args.push('--listFilesOnly');
const { stdout } = (0, node_child_process_1.spawnSync)(process.execPath, args, {
cwd: workingDir,
});
const root = (0, node_path_1.normalize)((0, node_path_1.resolve)(workingDir));
const normalize = candidate => (0, node_path_1.normalize)((0, node_path_1.isAbsolute)(candidate) ? candidate : (0, node_path_1.resolve)(workingDir, candidate));
// Normalize casing only on Windows; POSIX stays case-sensitive to match fs semantics.
const toComparable = path => (node_process_1.platform === 'win32' ? path.toLowerCase() : path);
const rootComparable = toComparable(root);
const isInsideRoot = candidate => {
const comparable = toComparable(candidate);
return (comparable === rootComparable || comparable.startsWith(`${rootComparable}${node_path_1.sep}`));
};
const isNodeModules = candidate => candidate.split(node_path_1.sep).includes('node_modules');
const allPaths = stdout
.toString()
// tsc may emit LF or CRLF depending on shell/platform; accept both.
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.map(normalize)
.filter(path => !isNodeModules(path));
const insideRoot = allPaths.filter(isInsideRoot);
// Prefer paths within the project root. On Windows, edge cases (UNC paths, junctions, etc.)
// can cause all paths to be filtered out. Fall back to unbounded list. See docs/faq.md.
return insideRoot.length ? insideRoot : allPaths;
};
exports.getCompileFiles = getCompileFiles;
const stripKnownExt = path => {
return path.replace(/(\.d\.(?:ts|mts|cts)|\.(?:mjs|cjs|js))$/, '');
};
exports.stripKnownExt = stripKnownExt;
const ensureDotSlash = path => {
return path.startsWith('./') ? path : `./${path}`;
};
exports.ensureDotSlash = ensureDotSlash;
const readExportsConfig = async (configPath, pkgDir) => {
const abs = (0, node_path_1.isAbsolute)(configPath)
? configPath
: configPath.startsWith('.')
? (0, node_path_1.resolve)(pkgDir, configPath)
: (0, node_path_1.resolve)((0, node_process_1.cwd)(), configPath);
const raw = await (0, promises_1.readFile)(abs, 'utf8');
let parsed;
try {
parsed = JSON.parse(raw);
}
catch (err) {
throw new Error(`Invalid JSON in --exports-config (${configPath}): ${err.message}`, {
cause: err,
});
}
const { entries, main } = parsed;
if (!entries ||
!Array.isArray(entries) ||
entries.some(item => typeof item !== 'string')) {
throw new Error('--exports-config expects an object with an "entries" array of strings');
}
if (main && typeof main !== 'string') {
throw new Error('--exports-config "main" must be a string when provided');
}
const normalize = value => ensureDotSlash(value.replace(/\\/g, '/'));
const normalizedEntries = [...new Set(entries.map(normalize))];
const normalizedMain = main ? normalize(main) : null;
return { entries: normalizedEntries, main: normalizedMain };
};
exports.readExportsConfig = readExportsConfig;
const getSubpath = (mode, relFromRoot) => {
const parsed = (0, node_path_1.parse)(relFromRoot);
const segments = parsed.dir.split('/').filter(Boolean);
if (mode === 'name') {
return parsed.name ? `./${parsed.name}` : null;
}
if (mode === 'dir') {
const last = segments.at(-1);
return last ? `./${last}/*` : null;
}
if (mode === 'wildcard') {
const first = segments[0];
return first ? `./${first}/*` : null;
}
return null;
};
exports.getSubpath = getSubpath;
const generateExports = async (options) => {
const { mode, pkg, pkgDir, esmRoot, cjsRoot, mainDefaultKind, mainPath, entries, dryRun, } = options;
const toPosix = path => path.replace(/\\/g, '/');
const esmRootPosix = toPosix(esmRoot);
const cjsRootPosix = toPosix(cjsRoot);
const esmPrefix = toPosix((0, node_path_1.relative)(pkgDir, esmRoot));
const cjsPrefix = toPosix((0, node_path_1.relative)(pkgDir, cjsRoot));
const esmIgnore = ['node_modules/**'];
const cjsIgnore = ['node_modules/**'];
const baseMap = new Map();
const subpathMap = new Map();
const baseToSubpath = new Map();
if (cjsRootPosix.startsWith(`${esmRootPosix}/`)) {
esmIgnore.push(`${cjsRootPosix}/**`);
}
if (esmRootPosix.startsWith(`${cjsRootPosix}/`)) {
cjsIgnore.push(`${esmRootPosix}/**`);
}
const toWildcardValue = value => {
const dir = node_path_1.posix.dirname(value);
const file = node_path_1.posix.basename(value);
const dtsMatch = file.match(/(\.d\.(?:ts|mts|cts))$/i);
if (dtsMatch) {
const ext = dtsMatch[1];
return dir === '.' ? `./*${ext}` : `${dir}/*${ext}`;
}
const ext = node_path_1.posix.extname(file);
return dir === '.' ? `./*${ext}` : `${dir}/*${ext}`;
};
const expandEntriesBase = base => {
const variants = [base];
if (esmPrefix && cjsPrefix && esmPrefix !== cjsPrefix) {
const esmPrefixWithSlash = `${esmPrefix}/`;
const cjsPrefixWithSlash = `${cjsPrefix}/`;
if (base.startsWith(esmPrefixWithSlash)) {
variants.push(base.replace(esmPrefixWithSlash, cjsPrefixWithSlash));
}
if (base.startsWith(cjsPrefixWithSlash)) {
variants.push(base.replace(cjsPrefixWithSlash, esmPrefixWithSlash));
}
}
return variants;
};
const entriesBase = entries?.length
? new Set(entries.flatMap(entry => {
const normalized = stripKnownExt(entry.replace(/^\.\//, ''));
return expandEntriesBase(normalized);
}))
: null;
const recordPath = (kind, filePath, root) => {
const relPkg = toPosix((0, node_path_1.relative)(pkgDir, filePath));
const relFromRoot = toPosix((0, node_path_1.relative)(root, filePath));
const withDot = ensureDotSlash(relPkg);
const baseKey = stripKnownExt(relPkg);
const useEntriesSubpaths = Boolean(entriesBase);
if (entriesBase && !entriesBase.has(baseKey)) {
return;
}
const baseEntry = baseMap.get(baseKey) ?? {};
if (kind === 'types') {
baseEntry.types = baseEntry.types ?? withDot;
}
else {
baseEntry[kind] = withDot;
}
baseMap.set(baseKey, baseEntry);
const subpath = useEntriesSubpaths
? ensureDotSlash(stripKnownExt(relFromRoot))
: getSubpath(mode, relFromRoot);
const useWildcard = subpath?.includes('*');
if (kind === 'types') {
const mappedSubpath = baseToSubpath.get(baseKey);
if (mappedSubpath) {
const subEntry = subpathMap.get(mappedSubpath) ?? {};
const nextType = useWildcard ? toWildcardValue(withDot) : withDot;
subEntry.types = subEntry.types ?? nextType;
subpathMap.set(mappedSubpath, subEntry);
}
return;
}
if (subpath && subpath !== '.') {
const subEntry = subpathMap.get(subpath) ?? {};
subEntry[kind] = useWildcard ? toWildcardValue(withDot) : withDot;
subpathMap.set(subpath, subEntry);
baseToSubpath.set(baseKey, subpath);
}
};
for await (const file of (0, promises_1.glob)(`${esmRootPosix}/**/*.{js,mjs,d.ts,d.mts}`, {
ignore: esmIgnore,
})) {
if (/\.d\.(ts|mts)$/.test(file)) {
recordPath('types', file, esmRoot);
}
else {
recordPath('import', file, esmRoot);
}
}
for await (const file of (0, promises_1.glob)(`${cjsRootPosix}/**/*.{js,cjs,d.ts,d.cts}`, {
ignore: cjsIgnore,
})) {
if (/\.d\.(ts|cts)$/.test(file)) {
recordPath('types', file, cjsRoot);
}
else {
recordPath('require', file, cjsRoot);
}
}
const exportsMap = {};
const mainBase = mainPath ? stripKnownExt(mainPath.replace(/^\.\//, '')) : null;
const mainEntry = mainBase ? (baseMap.get(mainBase) ?? {}) : {};
if (mainPath) {
const rootEntry = {};
if (mainEntry.types) {
rootEntry.types = mainEntry.types;
}
if (mainDefaultKind === 'import') {
rootEntry.import = mainEntry.import ?? ensureDotSlash(mainPath);
if (mainEntry.require) {
rootEntry.require = mainEntry.require;
}
}
else {
rootEntry.require = mainEntry.require ?? ensureDotSlash(mainPath);
if (mainEntry.import) {
rootEntry.import = mainEntry.import;
}
}
rootEntry.default = ensureDotSlash(mainPath);
exportsMap['.'] = rootEntry;
}
const defaultKind = mainDefaultKind ?? 'import';
for (const [subpath, entry] of subpathMap.entries()) {
const out = {};
if (entry.types) {
out.types = entry.types;
}
if (entry.import) {
out.import = entry.import;
}
if (entry.require) {
out.require = entry.require;
}
const def = defaultKind === 'import'
? (entry.import ?? entry.require)
: (entry.require ?? entry.import);
if (def) {
out.default = def;
}
if (Object.keys(out).length) {
exportsMap[subpath] = out;
}
}
if (!exportsMap['.']) {
const firstNonWildcard = [...subpathMap.entries()].find(([key]) => !key.includes('*'));
if (firstNonWildcard) {
const [, entry] = firstNonWildcard;
const out = {};
if (entry.types) {
out.types = entry.types;
}
if (entry.import) {
out.import = entry.import;
}
if (entry.require) {
out.require = entry.require;
}
const def = defaultKind === 'import'
? (entry.import ?? entry.require)
: (entry.require ?? entry.import);
if (def) {
out.default = def;
}
if (Object.keys(out).length) {
exportsMap['.'] = out;
}
}
}
if (Object.keys(exportsMap).length) {
if (dryRun) {
return { exportsMap };
}
const pkgJson = {
...pkg.packageJson,
exports: exportsMap,
};
await (0, promises_1.writeFile)(pkg.path, `${JSON.stringify(pkgJson, null, 2)}\n`);
}
return { exportsMap };
};
exports.generateExports = generateExports;
const hazardPackageFromMessage = message => {
if (!message)
return null;
const match = /Package '([^']+)'/.exec(message);
return match?.[1] ?? null;
};
exports.hazardPackageFromMessage = hazardPackageFromMessage;
const filterDualPackageDiagnostics = (diagnostics, allowlist = new Set()) => {
if (!diagnostics?.length)
return [];
const allowlistSet = allowlist instanceof Set ? allowlist : new Set(allowlist);
if (!allowlistSet.size)
return diagnostics;
return diagnostics.filter(diag => {
if (!diag)
return false;
if (!diag.code?.startsWith('dual-package'))
return true;
const pkg = hazardPackageFromMessage(diag.message ?? '');
if (!pkg)
return true;
return !allowlistSet.has(pkg);
});
};
exports.filterDualPackageDiagnostics = filterDualPackageDiagnostics;
const processDiagnosticsForFile = (diagnostics, projectDir, logDiagnosticsFn) => {
if (!diagnostics.length)
return false;
return logDiagnosticsFn(diagnostics, projectDir);
};
exports.processDiagnosticsForFile = processDiagnosticsForFile;
const exitOnDiagnostics = (hasError, exitFn = process.exit) => {
if (hasError) {
exitFn(1);
}
};
exports.exitOnDiagnostics = exitOnDiagnostics;
const maybeLinkNodeModules = async (projectRoot, subDir, symlinkFn = promises_1.symlink, findUpFn = find_up_1.findUp) => {
const nodeModules = await findUpFn('node_modules', {
cwd: projectRoot,
type: 'directory',
});
if (nodeModules) {
try {
await symlinkFn(nodeModules, (0, node_path_1.join)(subDir, 'node_modules'), 'junction');
}
catch (err) {
if (err?.code === 'EEXIST')
return;
logWarn(`Failed to link node_modules into temp workspace (falling back to existing resolution): ${err.message}`);
}
}
};
exports.maybeLinkNodeModules = maybeLinkNodeModules;
const runExportsValidationBlock = async (options) => {
const { exportsOpt, exportsConfigData, exportsValidate, pkg, pkgDir, esmRoot, cjsRoot, mainDefaultKind, mainPath, logWarnFn = logWarn, logFn = log, generateExportsFn = generateExports, } = options;
if (!exportsOpt && !exportsConfigData && !exportsValidate) {
return { exportsMap: null };
}
if (exportsValidate && !exportsOpt && !exportsConfigData) {
logWarnFn('--exports-validate has no effect without --exports or --exports-config; No exports were written.');
}
const result = await generateExportsFn({
mode: exportsOpt,
pkg,
pkgDir,
esmRoot,
cjsRoot,
mainDefaultKind,
mainPath: exportsConfigData?.main ?? mainPath,
entries: exportsConfigData?.entries,
dryRun: exportsValidate,
});
if (exportsValidate) {
logFn('Exports validation successful.');
}
return result;
};
exports.runExportsValidationBlock = runExportsValidationBlock;