Skip to content

Instantly share code, notes, and snippets.

@qgustavor
Last active February 17, 2026 21:03
Show Gist options
  • Select an option

  • Save qgustavor/e0432e75d131d3460db5901a5fb23992 to your computer and use it in GitHub Desktop.

Select an option

Save qgustavor/e0432e75d131d3460db5901a5fb23992 to your computer and use it in GitHub Desktop.
Migrates a TypeScript codebase from .ts file extensions to .cts or .mts

deno run --allow-read --allow-write --allow-run --ignore-env https://gist.github.com/qgustavor/e0432e75d131d3460db5901a5fb23992/raw/ts-to-cts-mts.mts

#!/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
@qgustavor
Copy link
Author

Great, it does not work for me due to this Nuxt issue: nuxt/nuxt#33845

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment