Skip to content

Commit b500ba6

Browse files
committed
feat: support TS cpconfig files
1 parent 127fc0a commit b500ba6

File tree

2 files changed

+319
-4
lines changed

2 files changed

+319
-4
lines changed

src/cli.spec.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
1+
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
22
import * as os from 'node:os';
33
import * as path from 'node:path';
44
import { describe, expect, test } from 'vitest';
@@ -95,6 +95,48 @@ describe('cli', () => {
9595
});
9696
});
9797

98+
test('loads configuration from a TypeScript module when runtime support is available', async () => {
99+
await withTempDir(async (cwd) => {
100+
await linkLocalModule(cwd, 'typescript');
101+
102+
const modulePath = path.join(cwd, 'cpconfig.config.ts');
103+
104+
await writeFile(
105+
modulePath,
106+
`export default {\n files: {\n 'ts-output.txt': { contents: 'from typescript' }\n }\n};\n`,
107+
);
108+
109+
await writeFile(
110+
path.join(cwd, 'package.json'),
111+
JSON.stringify(
112+
{
113+
...packageTemplate,
114+
config: {
115+
cpconfig: './cpconfig.config.ts',
116+
},
117+
},
118+
null,
119+
2,
120+
),
121+
);
122+
123+
const stdout = createBuffer();
124+
const stderr = createBuffer();
125+
126+
const exitCode = await runCli([], { cwd, stdout, stderr });
127+
128+
expect(exitCode).toBe(0);
129+
expect(stderr.toString()).toBe('');
130+
131+
await expect(readFile(path.join(cwd, 'ts-output.txt'), 'utf8')).resolves.toBe(
132+
'from typescript',
133+
);
134+
135+
const gitignore = await readFile(path.join(cwd, '.gitignore'), 'utf8');
136+
expect(gitignore).toContain('ts-output.txt');
137+
});
138+
});
139+
98140
test('supports config modules exporting factories', async () => {
99141
await withTempDir(async (cwd) => {
100142
const modulePath = path.join(cwd, 'cpconfig.factory.mjs');
@@ -290,6 +332,27 @@ describe('cli', () => {
290332
});
291333
});
292334

335+
async function linkLocalModule(cwd: string, moduleName: string): Promise<void> {
336+
const source = path.join(process.cwd(), 'node_modules', moduleName);
337+
const destinationDir = path.join(cwd, 'node_modules');
338+
const destination = path.join(destinationDir, moduleName);
339+
340+
await mkdir(destinationDir, { recursive: true });
341+
342+
try {
343+
await symlink(source, destination, 'dir');
344+
} catch (error) {
345+
const code = (error as NodeJS.ErrnoException).code;
346+
if (code === 'EEXIST') {
347+
return;
348+
}
349+
if (code === 'ENOENT') {
350+
throw new Error(`Module ${moduleName} is not installed under node_modules`);
351+
}
352+
throw error;
353+
}
354+
}
355+
293356
async function withTempDir<T>(callback: (cwd: string) => Promise<T>): Promise<T> {
294357
const cwd = await mkdtemp(path.join(os.tmpdir(), 'cpconfig-cli-'));
295358

src/cli.ts

Lines changed: 255 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
2-
import { promises as fs } from 'node:fs';
3-
import { createRequire } from 'node:module';
2+
import { promises as fs, readFileSync } from 'node:fs';
3+
import ModuleConstructor, { createRequire } from 'node:module';
44
import * as path from 'node:path';
55
import { fileURLToPath, pathToFileURL } from 'node:url';
66
import {
@@ -11,6 +11,18 @@ import {
1111
type SyncResult,
1212
} from './index';
1313

14+
const TYPE_SCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.cts', '.mts']);
15+
16+
type TypeScriptModule = typeof import('typescript');
17+
18+
type TypeScriptSupportState = {
19+
mode: 'require' | 'import';
20+
source: string;
21+
};
22+
23+
let cachedTypeScriptSupport: TypeScriptSupportState | undefined;
24+
let manualTypeScriptHookInstalled = false;
25+
1426
type CliRunOptions = {
1527
cwd?: string;
1628
stdout?: Pick<NodeJS.WritableStream, 'write'>;
@@ -259,7 +271,7 @@ async function loadConfigModule({
259271

260272
let imported: Record<string, unknown>;
261273
try {
262-
imported = await import(url);
274+
imported = await importResolvedModule({ resolvedPath, url, packageDir });
263275
} catch (error) {
264276
const message = error instanceof Error ? error.message : String(error);
265277
throw new Error(
@@ -306,6 +318,246 @@ function resolveModuleSpecifier(
306318
}
307319
}
308320

321+
async function importResolvedModule({
322+
resolvedPath,
323+
url,
324+
packageDir,
325+
}: {
326+
resolvedPath: string;
327+
url: string;
328+
packageDir: string;
329+
}): Promise<Record<string, unknown>> {
330+
if (!isTypeScriptModule(resolvedPath)) {
331+
return (await import(url)) as Record<string, unknown>;
332+
}
333+
334+
const requireFromPkg = createRequire(path.join(packageDir, 'package.json'));
335+
const support = await ensureTypeScriptSupport(resolvedPath, requireFromPkg);
336+
337+
if (support.mode === 'require') {
338+
try {
339+
return requireFromPkg(resolvedPath) as Record<string, unknown>;
340+
} catch (error) {
341+
if (isErrRequireEsm(error)) {
342+
return (await import(url)) as Record<string, unknown>;
343+
}
344+
throw error;
345+
}
346+
}
347+
348+
return (await import(url)) as Record<string, unknown>;
349+
}
350+
351+
function isTypeScriptModule(filePath: string): boolean {
352+
if (filePath.endsWith('.d.ts')) {
353+
return false;
354+
}
355+
356+
const extension = path.extname(filePath).toLowerCase();
357+
return TYPE_SCRIPT_EXTENSIONS.has(extension);
358+
}
359+
360+
type LoaderAttemptResult =
361+
| { status: 'loaded'; state: TypeScriptSupportState }
362+
| { status: 'missing' }
363+
| { status: 'failed'; message: string };
364+
365+
async function ensureTypeScriptSupport(
366+
modulePath: string,
367+
requireFromPkg: NodeJS.Require,
368+
): Promise<TypeScriptSupportState> {
369+
if (cachedTypeScriptSupport) {
370+
return cachedTypeScriptSupport;
371+
}
372+
373+
const missing: string[] = [];
374+
const errors: string[] = [];
375+
376+
const candidates: Array<{ label: string; run: () => Promise<LoaderAttemptResult> }> = [
377+
{
378+
label: 'ts-node/register/transpile-only',
379+
run: () => registerCommonJsLoader(requireFromPkg, 'ts-node/register/transpile-only'),
380+
},
381+
{
382+
label: 'ts-node/register',
383+
run: () => registerCommonJsLoader(requireFromPkg, 'ts-node/register'),
384+
},
385+
{
386+
label: 'typescript',
387+
run: () => installManualTypeScriptHook(requireFromPkg),
388+
},
389+
];
390+
391+
for (const candidate of candidates) {
392+
const result = await candidate.run();
393+
394+
if (result.status === 'loaded') {
395+
cachedTypeScriptSupport = result.state;
396+
return result.state;
397+
}
398+
399+
if (result.status === 'missing') {
400+
missing.push(candidate.label);
401+
continue;
402+
}
403+
404+
errors.push(`${candidate.label}: ${result.message}`);
405+
}
406+
407+
const messageLines = [
408+
`Unable to load TypeScript configuration module at ${modulePath}.`,
409+
'Install one of "ts-node" or "typescript" in your project to enable TypeScript configs.',
410+
];
411+
412+
if (missing.length > 0) {
413+
messageLines.push(`Missing dependencies: ${missing.join(', ')}`);
414+
}
415+
416+
if (errors.length > 0) {
417+
messageLines.push('Errors encountered while initialising TypeScript support:');
418+
for (const error of errors) {
419+
messageLines.push(` - ${error}`);
420+
}
421+
}
422+
423+
throw new Error(messageLines.join('\n'));
424+
}
425+
426+
async function registerCommonJsLoader(
427+
requireFromPkg: NodeJS.Require,
428+
specifier: string,
429+
): Promise<LoaderAttemptResult> {
430+
try {
431+
requireFromPkg(specifier);
432+
return { status: 'loaded', state: { mode: 'require', source: specifier } };
433+
} catch (error) {
434+
if (isModuleNotFoundError(error, specifier)) {
435+
return { status: 'missing' };
436+
}
437+
438+
const message = error instanceof Error ? error.message : String(error);
439+
return { status: 'failed', message };
440+
}
441+
}
442+
443+
async function installManualTypeScriptHook(
444+
requireFromPkg: NodeJS.Require,
445+
): Promise<LoaderAttemptResult> {
446+
if (manualTypeScriptHookInstalled) {
447+
return { status: 'loaded', state: { mode: 'require', source: 'typescript' } };
448+
}
449+
450+
let typescript: TypeScriptModule;
451+
try {
452+
typescript = requireFromPkg('typescript') as TypeScriptModule;
453+
} catch (error) {
454+
if (isModuleNotFoundError(error, 'typescript')) {
455+
return { status: 'missing' };
456+
}
457+
458+
const message = error instanceof Error ? error.message : String(error);
459+
return { status: 'failed', message };
460+
}
461+
462+
const extensions = (
463+
ModuleConstructor as unknown as {
464+
_extensions: Record<string, (module: NodeJS.Module, filename: string) => void>;
465+
}
466+
)._extensions;
467+
468+
for (const extension of TYPE_SCRIPT_EXTENSIONS) {
469+
if (!extensions[extension]) {
470+
extensions[extension] = createTypeScriptExtensionHandler(typescript, extension);
471+
}
472+
}
473+
474+
manualTypeScriptHookInstalled = true;
475+
return { status: 'loaded', state: { mode: 'require', source: 'typescript' } };
476+
}
477+
478+
function createTypeScriptExtensionHandler(
479+
typescript: TypeScriptModule,
480+
extension: string,
481+
): (module: NodeJS.Module, filename: string) => void {
482+
return (module, filename) => {
483+
const source = readFileSync(filename, 'utf8');
484+
const result = typescript.transpileModule(source, {
485+
compilerOptions: createTypeScriptTranspileOptions(typescript, extension),
486+
fileName: filename,
487+
reportDiagnostics: true,
488+
});
489+
490+
if (result.diagnostics && result.diagnostics.length > 0) {
491+
const formatted = formatTypeScriptDiagnostics(typescript, result.diagnostics, filename);
492+
throw new Error(`Failed to compile ${filename}:\n${formatted}`);
493+
}
494+
495+
const compiled = module as unknown as { _compile(code: string, filename: string): void };
496+
compiled._compile(result.outputText, filename);
497+
};
498+
}
499+
500+
function createTypeScriptTranspileOptions(
501+
typescript: TypeScriptModule,
502+
extension: string,
503+
): import('typescript').CompilerOptions {
504+
const options: import('typescript').CompilerOptions = {
505+
module: typescript.ModuleKind.CommonJS,
506+
target: typescript.ScriptTarget.ES2020,
507+
esModuleInterop: true,
508+
sourceMap: false,
509+
allowSyntheticDefaultImports: true,
510+
resolveJsonModule: true,
511+
};
512+
513+
if (extension === '.tsx') {
514+
options.jsx = typescript.JsxEmit.React;
515+
}
516+
517+
return options;
518+
}
519+
520+
function formatTypeScriptDiagnostics(
521+
typescript: TypeScriptModule,
522+
diagnostics: readonly import('typescript').Diagnostic[],
523+
filename: string,
524+
): string {
525+
const host: import('typescript').FormatDiagnosticsHost = {
526+
getCanonicalFileName: (fileName) => fileName,
527+
getCurrentDirectory: () => path.dirname(filename),
528+
getNewLine: () => '\n',
529+
};
530+
531+
return typescript.formatDiagnostics(diagnostics, host);
532+
}
533+
534+
function isModuleNotFoundError(error: unknown, specifier: string): boolean {
535+
if (!error || typeof error !== 'object') {
536+
return false;
537+
}
538+
539+
const code = (error as NodeJS.ErrnoException).code;
540+
if (code !== 'MODULE_NOT_FOUND') {
541+
return false;
542+
}
543+
544+
const message = (error as NodeJS.ErrnoException).message;
545+
if (typeof message !== 'string') {
546+
return false;
547+
}
548+
549+
return message.includes(`'${specifier}'`);
550+
}
551+
552+
function isErrRequireEsm(error: unknown): boolean {
553+
return Boolean(
554+
error &&
555+
typeof error === 'object' &&
556+
'code' in (error as Record<string, unknown>) &&
557+
(error as NodeJS.ErrnoException).code === 'ERR_REQUIRE_ESM',
558+
);
559+
}
560+
309561
function selectConfigExport(imported: Record<string, unknown>, resolvedPath: string): unknown {
310562
if ('default' in imported && imported.default !== undefined) {
311563
return imported.default;

0 commit comments

Comments
 (0)