deno run --allow-read --allow-write --allow-run --ignore-env https://gist.github.com/qgustavor/e0432e75d131d3460db5901a5fb23992/raw/ts-to-cts-mts.mts
Last active
February 17, 2026 21:03
-
-
Save qgustavor/e0432e75d131d3460db5901a5fb23992 to your computer and use it in GitHub Desktop.
Migrates a TypeScript codebase from .ts file extensions to .cts or .mts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --ignore-env | |
| /** | |
| * ts-to-cts-mts | |
| * | |
| * Migrates a TypeScript codebase from .ts file extensions to .cts or .mts, | |
| * determined per-file by inspecting the actual module system in use. All | |
| * import, export, and require() specifiers that reference renamed files are | |
| * updated accordingly. | |
| * | |
| * Background: the .ts extension conflicts with the MPEG-2 Transport Stream | |
| * container format used in video pipelines, causing ambiguity in software that | |
| * handles both source code and media files (like operating systems and file | |
| * managers). Renaming to .cts/.mts resolves this with minimal breaking changes. | |
| * | |
| * Usage: | |
| * deno run --allow-read --allow-write --allow-run --ignore-env https://gist.github.com/qgustavor/e0432e75d131d3460db5901a5fb23992/raw/ts-to-cts-mts.mts [root-dir] | |
| * | |
| * root-dir defaults to the current working directory. | |
| * | |
| * How it works: | |
| * 1. Collects all .ts files, honouring .gitignore rules. Uses git ls-files | |
| * when inside a git repository, falls back to npm:ignore otherwise. | |
| * 2. Parses each file with the TypeScript compiler (via ts-morph) to | |
| * determine whether it uses CommonJS or ES Modules. | |
| * 3. Rewrites all affected import/export/require specifiers across the | |
| * codebase using AST manipulation — no regular expressions. | |
| * 4. Renames the files. | |
| * | |
| * Dependencies (resolved from the npm registry and cached by Deno locally; | |
| * no separate install step required): | |
| * npm:ts-morph@27 TypeScript compiler API wrapper | |
| * npm:ignore@6 .gitignore rule engine (used by eslint, gitbook, etc.) | |
| */ | |
| import { Project, SyntaxKind } from 'npm:ts-morph@27' | |
| import Ignore from 'npm:ignore@6' | |
| import * as path from 'node:path' | |
| import * as fs from 'node:fs/promises' | |
| // --------------------------------------------------------------------------- | |
| // Logging | |
| // --------------------------------------------------------------------------- | |
| const dim = (s: string) => `\x1b[2m${s}\x1b[0m` | |
| const bold = (s: string) => `\x1b[1m${s}\x1b[0m` | |
| const green = (s: string) => `\x1b[32m${s}\x1b[0m` | |
| const yellow = (s: string) => `\x1b[33m${s}\x1b[0m` | |
| const red = (s: string) => `\x1b[31m${s}\x1b[0m` | |
| function log (msg: string) { | |
| console.log(msg) | |
| } | |
| function info (label: string, msg: string) { | |
| log(` ${bold(label.padEnd(10))} ${msg}`) | |
| } | |
| function warn (msg: string) { | |
| log(` ${yellow('warning')} ${msg}`) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Gitignore / file collection | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Ask git for every file it tracks or sees as untracked-but-not-ignored. | |
| * Returns null when git is unavailable or the directory is not a repository. | |
| */ | |
| async function getGitFiles (root: string): Promise<Set<string> | null> { | |
| const { code, stdout } = await new Deno.Command('git', { | |
| args: ['ls-files', '--cached', '--others', '--exclude-standard'], | |
| cwd: root, | |
| stdout: 'piped', | |
| stderr: 'null' | |
| }).output() | |
| if (code !== 0) return null | |
| return new Set( | |
| new TextDecoder() | |
| .decode(stdout) | |
| .split('\n') | |
| .filter(Boolean) | |
| .map(rel => path.join(root, rel)) | |
| ) | |
| } | |
| /** | |
| * Walk the directory tree collecting every .gitignore file and return a | |
| * predicate that mirrors git's cascading-gitignore evaluation: rules are | |
| * applied relative to the directory in which they are declared. | |
| * Used only when git ls-files is unavailable. | |
| */ | |
| async function buildIgnoreFilter ( | |
| root: string | |
| ): Promise<(absPath: string) => boolean> { | |
| const instances: Array<{ dir: string; ig: ReturnType<typeof Ignore> }> = [] | |
| async function walk(dir: string) { | |
| const entries = await fs.readdir(dir, { withFileTypes: true }) | |
| const hasGitignore = entries.some( | |
| e => e.name === '.gitignore' && e.isFile() | |
| ) | |
| if (hasGitignore) { | |
| const content = await fs.readFile(path.join(dir, '.gitignore'), 'utf8') | |
| instances.push({ dir, ig: Ignore().add(content) }) | |
| } | |
| for (const entry of entries) { | |
| if (entry.name === 'node_modules' || entry.name === '.git') continue | |
| if (entry.isDirectory()) await walk(path.join(dir, entry.name)) | |
| } | |
| } | |
| await walk(root) | |
| return (absPath: string): boolean => { | |
| for (const { dir, ig } of instances) { | |
| const rel = path.relative(dir, absPath) | |
| if (rel.startsWith('..')) continue | |
| if (ig.ignores(rel)) return true | |
| } | |
| return false | |
| } | |
| } | |
| const SOURCE_EXTS = new Set([ | |
| '.ts', | |
| '.tsx', | |
| '.js', | |
| '.jsx', | |
| '.mjs', | |
| '.cjs', | |
| '.mts', | |
| '.cts' | |
| ]) | |
| async function collectFiles ( | |
| root: string | |
| ): Promise<{ tsFiles: string[]; allSourceFiles: string[] }> { | |
| const gitFiles = await getGitFiles(root) | |
| if (gitFiles === null) { | |
| warn( | |
| 'git ls-files unavailable — falling back to npm:ignore for gitignore evaluation.' | |
| ) | |
| } | |
| const isIgnoredByParser = | |
| gitFiles === null ? await buildIgnoreFilter(root) : null | |
| const isIgnored = (absPath: string): boolean => | |
| gitFiles !== null ? !gitFiles.has(absPath) : isIgnoredByParser!(absPath) | |
| const tsFiles: string[] = [] | |
| const allSourceFiles: string[] = [] | |
| async function walk(dir: string) { | |
| const entries = await fs.readdir(dir, { withFileTypes: true }) | |
| for (const entry of entries) { | |
| if (entry.name === 'node_modules' || entry.name === '.git') continue | |
| const full = path.join(dir, entry.name) | |
| if (entry.isDirectory()) { | |
| await walk(full) | |
| continue | |
| } | |
| if (isIgnored(full)) continue | |
| const ext = path.extname(entry.name) | |
| if (SOURCE_EXTS.has(ext)) allSourceFiles.push(full) | |
| if (ext === '.ts') tsFiles.push(full) | |
| } | |
| } | |
| await walk(root) | |
| return { tsFiles, allSourceFiles } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // CJS vs ESM detection via the TypeScript compiler AST | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Determines whether a source file is CommonJS or ES Module by inspecting | |
| * its AST. No regular expressions are used. | |
| * | |
| * A file is classified as ESM when it contains at least one ESM signal and | |
| * no CJS signals. Everything else is treated as CommonJS. | |
| * | |
| * ESM signals: | |
| * - Top-level import declarations | |
| * - Top-level export declarations | |
| * - export default expressions | |
| * - Named export declarations (export const / function / class …) | |
| * | |
| * CJS signals: | |
| * - require(…) call expressions | |
| * - module.exports property access | |
| * - exports.xxx property access | |
| */ | |
| function detectModuleType (filePath: string, project: Project): 'cjs' | 'esm' { | |
| const sf = project.addSourceFileAtPath(filePath) | |
| let esm = 0 | |
| let cjs = 0 | |
| esm += sf.getImportDeclarations().length | |
| esm += sf.getExportDeclarations().length | |
| esm += sf.getExportAssignments().filter(a => !a.isExportEquals()).length | |
| if (sf.getExportedDeclarations().size > 0) esm++ | |
| sf.forEachDescendant(node => { | |
| if (node.getKind() === SyntaxKind.CallExpression) { | |
| const expr = node.asKindOrThrow(SyntaxKind.CallExpression).getExpression() | |
| if ( | |
| expr.getKind() === SyntaxKind.Identifier && | |
| expr.getText() === 'require' | |
| ) | |
| cjs++ | |
| } | |
| if (node.getKind() === SyntaxKind.PropertyAccessExpression) { | |
| const pa = node.asKindOrThrow(SyntaxKind.PropertyAccessExpression) | |
| const obj = pa.getExpression().getText() | |
| if (obj === 'module' && pa.getName() === 'exports') cjs++ | |
| if (obj === 'exports') cjs++ | |
| } | |
| }) | |
| sf.forget() | |
| return esm > 0 && cjs === 0 ? 'esm' : 'cjs' | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Reference rewriting via the TypeScript compiler AST | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Rewrites all relative import/export-from/require()/import() specifiers in | |
| * the given file that resolve to a path present in renameMap. | |
| * Edits are applied via AST manipulation and saved in place. | |
| * Returns true if the file was modified. | |
| */ | |
| async function fixReferences ( | |
| filePath: string, | |
| renameMap: Map<string, string>, | |
| project: Project | |
| ): Promise<boolean> { | |
| const sf = project.addSourceFileAtPath(filePath) | |
| const dir = path.dirname(filePath) | |
| let modified = false | |
| const rewrite = (specifier: string): string | null => { | |
| if (!specifier.startsWith('.')) return null | |
| const base = path.join(dir, specifier) | |
| const candidates = [ | |
| path.normalize(base), | |
| path.normalize(`${base}.ts`), | |
| path.normalize(path.join(base, 'index.ts')) | |
| ] | |
| for (const candidate of candidates) { | |
| if (renameMap.has(candidate)) { | |
| let rel = path | |
| .relative(dir, renameMap.get(candidate)!) | |
| .replaceAll('\\', '/') | |
| if (!rel.startsWith('.')) rel = `./${rel}` | |
| return rel | |
| } | |
| } | |
| return null | |
| } | |
| for (const decl of sf.getImportDeclarations()) { | |
| const newSpec = rewrite(decl.getModuleSpecifierValue()) | |
| if (newSpec) { | |
| decl.setModuleSpecifier(newSpec) | |
| modified = true | |
| } | |
| } | |
| for (const decl of sf.getExportDeclarations()) { | |
| const specifier = decl.getModuleSpecifierValue() | |
| if (!specifier) continue | |
| const newSpec = rewrite(specifier) | |
| if (newSpec) { | |
| decl.setModuleSpecifier(newSpec) | |
| modified = true | |
| } | |
| } | |
| sf.forEachDescendant(node => { | |
| if (node.getKind() !== SyntaxKind.CallExpression) return | |
| const call = node.asKindOrThrow(SyntaxKind.CallExpression) | |
| const expr = call.getExpression() | |
| const isRequire = | |
| expr.getKind() === SyntaxKind.Identifier && expr.getText() === 'require' | |
| const isDynamicImport = expr.getKind() === SyntaxKind.ImportKeyword | |
| if (!isRequire && !isDynamicImport) return | |
| const args = call.getArguments() | |
| if (args.length === 0) return | |
| const firstArg = args[0] | |
| if (firstArg.getKind() !== SyntaxKind.StringLiteral) return | |
| const newSpec = rewrite( | |
| firstArg.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralValue() | |
| ) | |
| if (newSpec) { | |
| firstArg.replaceWithText(JSON.stringify(newSpec)) | |
| modified = true | |
| } | |
| }) | |
| if (modified) await sf.save() | |
| sf.forget() | |
| return modified | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Entry point | |
| // --------------------------------------------------------------------------- | |
| const root = path.resolve(Deno.args[0] ?? Deno.cwd()) | |
| log('') | |
| log(bold('ts-to-cts-mts') + dim(' TypeScript extension migration tool')) | |
| log(dim('─'.repeat(56))) | |
| log(` ${dim('root')} ${root}`) | |
| log('') | |
| log(dim('Collecting files...')) | |
| const { tsFiles, allSourceFiles } = await collectFiles(root) | |
| log(` ${tsFiles.length} .ts file(s) found\n`) | |
| if (tsFiles.length === 0) { | |
| log('Nothing to do.') | |
| Deno.exit(0) | |
| } | |
| // skipLoadingLibFiles: structural AST only, type-checking is not required | |
| const detectionProject = new Project({ skipLoadingLibFiles: true }) | |
| const rewriteProject = new Project({ skipLoadingLibFiles: true }) | |
| log(dim('Analysing module format...')) | |
| log('') | |
| const renameMap = new Map<string, string>() | |
| for (const file of tsFiles) { | |
| const type = detectModuleType(file, detectionProject) | |
| const newExt = type === 'esm' ? '.mts' : '.cts' | |
| const newPath = file.replace(/\.ts$/, newExt) | |
| renameMap.set(path.normalize(file), path.normalize(newPath)) | |
| info( | |
| type.toUpperCase(), | |
| dim(path.relative(root, path.dirname(file)) + path.sep) + | |
| path.basename(file) + | |
| dim(' -> ') + | |
| path.basename(newPath) | |
| ) | |
| } | |
| log('') | |
| log(dim('Rewriting specifiers...')) | |
| log('') | |
| let rewriteCount = 0 | |
| for (const file of allSourceFiles) { | |
| if (await fixReferences(file, renameMap, rewriteProject)) { | |
| info('updated', path.relative(root, file)) | |
| rewriteCount++ | |
| } | |
| } | |
| if (rewriteCount === 0) log(dim(' No specifiers required updating.')) | |
| log('') | |
| log(dim('Renaming files...')) | |
| log('') | |
| for (const [oldPath, newPath] of renameMap) { | |
| await Deno.rename(oldPath, newPath) | |
| info( | |
| green('renamed'), | |
| dim(path.relative(root, path.dirname(oldPath)) + path.sep) + | |
| path.basename(oldPath) + | |
| dim(' -> ') + | |
| green(path.basename(newPath)) | |
| ) | |
| } | |
| log('') | |
| log( | |
| green('Migration complete.') + | |
| ` ${renameMap.size} file(s) renamed, ${rewriteCount} file(s) updated.` | |
| ) | |
| log('') | |
| // yes, I used Claude. If you are bothered by this, feel free to make yourself an alternative, I would love to use and promote it |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Great, it does not work for me due to this Nuxt issue: nuxt/nuxt#33845