feat(medusa-cli): add codemod command + codemod for replacing zod imports (#14520)

* feat(medusa-cli): add codemod command + codemod for replacing zod imports

* fixes
This commit is contained in:
Shahed Nasser
2026-01-14 08:48:04 +02:00
committed by GitHub
parent 8426fca710
commit 42235825ee
7 changed files with 666 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/cli": patch
---
feat(medusa-cli): add codemod command + codemod for replacing zod imports

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "@jest/globals"
import { getCodemod, listCodemods } from "../index"
describe("Codemod dispatcher", () => {
describe("listCodemods", () => {
it("should return array of available codemod names", () => {
const codemods = listCodemods()
expect(Array.isArray(codemods)).toBe(true)
expect(codemods.length).toBeGreaterThan(0)
expect(codemods).toContain("replace-zod-imports")
})
})
describe("getCodemod", () => {
it("should return codemod for valid name", () => {
const codemod = getCodemod("replace-zod-imports")
expect(codemod).not.toBeNull()
expect(codemod?.name).toBe("replace-zod-imports")
expect(codemod?.description).toBeTruthy()
expect(typeof codemod?.run).toBe("function")
})
it("should return null for invalid codemod name", () => {
const codemod = getCodemod("non-existent-codemod")
expect(codemod).toBeNull()
})
it("should return codemod with correct interface", () => {
const codemod = getCodemod("replace-zod-imports")
expect(codemod).toHaveProperty("name")
expect(codemod).toHaveProperty("description")
expect(codemod).toHaveProperty("run")
})
})
})

View File

@@ -0,0 +1,324 @@
import fs from "fs"
import path from "path"
import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"
import replaceZodImports from "../replace-zod-imports"
describe("replace-zod-imports codemod", () => {
const tempDir = path.join(__dirname, "temp-test-codemod")
let originalCwd: string
beforeEach(() => {
// Save original working directory
originalCwd = process.cwd()
// Create temp directory for test files
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true })
}
fs.mkdirSync(tempDir, { recursive: true })
// Change to temp directory so codemod runs there
process.chdir(tempDir)
})
afterEach(() => {
// Restore original working directory
process.chdir(originalCwd)
// Clean up temp directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
describe("codemod metadata", () => {
it("should have correct name and description", () => {
expect(replaceZodImports.name).toBe("replace-zod-imports")
expect(replaceZodImports.description).toBeTruthy()
expect(typeof replaceZodImports.run).toBe("function")
})
})
describe("named import transformations", () => {
it("should transform named imports from zod", async () => {
const testFile = path.join(tempDir, "test1.ts")
fs.writeFileSync(testFile, `import { z, ZodSchema } from "zod"`)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
expect(result).toBe(
`import { z, ZodSchema } from "@medusajs/framework/zod"`
)
})
it("should transform named imports with single quotes", async () => {
const testFile = path.join(tempDir, "test2.ts")
fs.writeFileSync(testFile, `import { z } from 'zod'`)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
expect(result).toBe(`import { z } from "@medusajs/framework/zod"`)
})
})
describe("default import transformations", () => {
it("should transform default imports with identifier zod to aliased named imports", async () => {
const testFile = path.join(tempDir, "test3.ts")
fs.writeFileSync(testFile, `import zod from "zod"`)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
expect(result).toBe(`import { z as zod } from "@medusajs/framework/zod"`)
})
it("should transform default imports with identifier z to named imports", async () => {
const testFile = path.join(tempDir, "test3b.ts")
fs.writeFileSync(testFile, `import z from "zod"`)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
expect(result).toBe(`import { z } from "@medusajs/framework/zod"`)
})
})
describe("namespace import transformations", () => {
it("should transform namespace imports with identifier z", async () => {
const testFile = path.join(tempDir, "test4.ts")
fs.writeFileSync(testFile, `import * as z from "zod"`)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
expect(result).toBe(`import { z as z } from "@medusajs/framework/zod"`)
})
it("should transform namespace imports with custom identifier", async () => {
const testFile = path.join(tempDir, "test4b.ts")
fs.writeFileSync(testFile, `import * as validator from "zod"`)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
expect(result).toBe(
`import { z as validator } from "@medusajs/framework/zod"`
)
})
it("should transform namespace imports with zod identifier", async () => {
const testFile = path.join(tempDir, "test4c.ts")
fs.writeFileSync(testFile, `import * as zod from "zod"`)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
expect(result).toBe(`import { z as zod } from "@medusajs/framework/zod"`)
})
})
describe("type import transformations", () => {
it("should transform type imports", async () => {
const testFile = path.join(tempDir, "test5.ts")
fs.writeFileSync(testFile, `import type { ZodSchema } from "zod"`)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
expect(result).toBe(
`import type { ZodSchema } from "@medusajs/framework/zod"`
)
})
})
describe("require statement transformations", () => {
it("should transform require statements", async () => {
const testFile = path.join(tempDir, "test6.js")
fs.writeFileSync(testFile, `const zod = require("zod")`)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
expect(result).toBe(`const zod = require("@medusajs/framework/zod")`)
})
it("should transform require with single quotes", async () => {
const testFile = path.join(tempDir, "test7.js")
fs.writeFileSync(testFile, `const z = require('zod')`)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
expect(result).toBe(`const z = require("@medusajs/framework/zod")`)
})
})
describe("multiple imports in one file", () => {
it("should handle multiple zod imports", async () => {
const testFile = path.join(tempDir, "test8.ts")
const content = `import { z } from "zod"
import { something } from "other-package"
import type { ZodSchema } from "zod"
const zodRequire = require("zod")`
fs.writeFileSync(testFile, content)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
const expected = `import { z } from "@medusajs/framework/zod"
import { something } from "other-package"
import type { ZodSchema } from "@medusajs/framework/zod"
const zodRequire = require("@medusajs/framework/zod")`
expect(result).toBe(expected)
})
})
describe("dry-run mode", () => {
it("should not modify files in dry-run mode", async () => {
const testFile = path.join(tempDir, "test9.ts")
const originalContent = `import { z } from "zod"`
fs.writeFileSync(testFile, originalContent)
const result = await replaceZodImports.run({ dryRun: true })
const afterContent = fs.readFileSync(testFile, "utf8")
expect(afterContent).toBe(originalContent)
expect(result.filesModified).toBeGreaterThan(0)
expect(result.errors).toBe(0)
})
})
describe("files without zod imports", () => {
it("should not modify files without zod imports", async () => {
const testFile = path.join(tempDir, "test10.ts")
const originalContent = `import { something } from "other-package"`
fs.writeFileSync(testFile, originalContent)
await replaceZodImports.run({ dryRun: false })
const afterContent = fs.readFileSync(testFile, "utf8")
expect(afterContent).toBe(originalContent)
})
it("should not modify partial matches like zodiac", async () => {
const testFile = path.join(tempDir, "test11.ts")
const originalContent = `import { something } from "zodiac"`
fs.writeFileSync(testFile, originalContent)
await replaceZodImports.run({ dryRun: false })
const afterContent = fs.readFileSync(testFile, "utf8")
expect(afterContent).toBe(originalContent)
})
})
describe("multiple files", () => {
it("should handle multiple files with different extensions", async () => {
const file1 = path.join(tempDir, "file1.ts")
const file2 = path.join(tempDir, "file2.js")
const file3 = path.join(tempDir, "file3.tsx")
fs.writeFileSync(file1, `import { z } from "zod"`)
fs.writeFileSync(file2, `const z = require("zod")`)
fs.writeFileSync(file3, `import type { ZodType } from "zod"`)
const result = await replaceZodImports.run({ dryRun: false })
expect(fs.readFileSync(file1, "utf8")).toBe(
`import { z } from "@medusajs/framework/zod"`
)
expect(fs.readFileSync(file2, "utf8")).toBe(
`const z = require("@medusajs/framework/zod")`
)
expect(fs.readFileSync(file3, "utf8")).toBe(
`import type { ZodType } from "@medusajs/framework/zod"`
)
expect(result.filesModified).toBeGreaterThanOrEqual(3)
expect(result.errors).toBe(0)
})
})
describe("result reporting", () => {
it("should return correct counts", async () => {
const file1 = path.join(tempDir, "count1.ts")
const file2 = path.join(tempDir, "count2.ts")
const file3 = path.join(tempDir, "no-zod.ts")
fs.writeFileSync(file1, `import { z } from "zod"`)
fs.writeFileSync(file2, `import { z } from "zod"`)
fs.writeFileSync(file3, `import { x } from "other"`)
const result = await replaceZodImports.run({ dryRun: false })
expect(result.filesScanned).toBeGreaterThanOrEqual(2)
expect(result.filesModified).toBeGreaterThanOrEqual(2)
expect(result.errors).toBe(0)
})
it("should return zero counts when no files have zod imports", async () => {
const testFile = path.join(tempDir, "empty.ts")
fs.writeFileSync(testFile, `import { x } from "other"`)
const result = await replaceZodImports.run({ dryRun: false })
expect(result.filesScanned).toBe(0)
expect(result.filesModified).toBe(0)
expect(result.errors).toBe(0)
})
})
describe("file formatting preservation", () => {
it("should preserve whitespace and comments", async () => {
const testFile = path.join(tempDir, "formatted.ts")
const content = `// Header comment
import { z } from "zod"
// Function comment
export function validate() {
return z.string()
}`
fs.writeFileSync(testFile, content)
await replaceZodImports.run({ dryRun: false })
const result = fs.readFileSync(testFile, "utf8")
const expected = `// Header comment
import { z } from "@medusajs/framework/zod"
// Function comment
export function validate() {
return z.string()
}`
expect(result).toBe(expected)
})
})
describe("directory exclusions", () => {
it("should ignore files in src/admin directories", async () => {
const adminDir = path.join(tempDir, "src", "admin")
fs.mkdirSync(adminDir, { recursive: true })
const adminFile = path.join(adminDir, "admin-component.tsx")
const originalContent = `import { z } from "zod"`
fs.writeFileSync(adminFile, originalContent)
const regularFile = path.join(tempDir, "regular-file.ts")
fs.writeFileSync(regularFile, `import { z } from "zod"`)
const result = await replaceZodImports.run({ dryRun: false })
// Admin file should not be modified
const adminContent = fs.readFileSync(adminFile, "utf8")
expect(adminContent).toBe(originalContent)
// Regular file should be modified
const regularContent = fs.readFileSync(regularFile, "utf8")
expect(regularContent).toBe(`import { z } from "@medusajs/framework/zod"`)
// Result should only count the regular file
expect(result.filesModified).toBe(1)
})
})
})

View File

@@ -0,0 +1,26 @@
import type { Codemod } from "./types"
import replaceZodImports from "./replace-zod-imports"
/**
* Registry of available codemods
*/
const CODEMODS: Record<string, Codemod> = {
"replace-zod-imports": replaceZodImports,
}
/**
* Get a codemod by name
* @param name - The name of the codemod to retrieve
* @returns The codemod if found, null otherwise
*/
export function getCodemod(name: string): Codemod | null {
return CODEMODS[name] || null
}
/**
* List all available codemod names
* @returns Array of codemod names
*/
export function listCodemods(): string[] {
return Object.keys(CODEMODS)
}

View File

@@ -0,0 +1,165 @@
import fs from "fs"
import reporter from "../reporter/index"
import type { Codemod, CodemodOptions, CodemodResult } from "./types"
import { glob } from "glob"
const CODEMOD: Codemod = {
name: "replace-zod-imports",
description: "Replace all zod imports with @medusajs/framework/zod imports",
run: replaceZodImports,
}
export default CODEMOD
// Replacement patterns for zod imports
// Order matters: more specific patterns must come before general ones
const REPLACEMENTS = [
// Default import with identifier "zod": import zod from "zod" -> import { z as zod } from "@medusajs/framework/zod"
{
pattern: /import\s+zod\s+from\s+['"]zod['"]/g,
replacement: `import { z as zod } from "@medusajs/framework/zod"`,
},
// Default import with identifier "z": import z from "zod" -> import { z } from "@medusajs/framework/zod"
{
pattern: /import\s+z\s+from\s+['"]zod['"]/g,
replacement: `import { z } from "@medusajs/framework/zod"`,
},
// Namespace import with other identifier: import * as something from "zod" -> import { z as something } from "@medusajs/framework/zod"
{
pattern: /import\s+\*\s+as\s+(\w+)\s+from\s+['"]zod['"]/g,
replacement: `import { z as $1 } from "@medusajs/framework/zod"`,
},
// Named/type imports: import { z } from "zod" or import type { ZodSchema } from "zod"
{
pattern: /from\s+['"]zod['"]/g,
replacement: `from "@medusajs/framework/zod"`,
},
// CommonJS require: require("zod")
{
pattern: /require\s*\(\s*['"]zod['"]\s*\)/g,
replacement: `require("@medusajs/framework/zod")`,
},
]
const ZOD_IMPORT_PATTERN = /from\s+['"]zod['"]|require\s*\(\s*['"]zod['"]\s*\)/
/**
* Replace all zod imports with @medusajs/framework/zod imports
*/
async function replaceZodImports(
options: CodemodOptions
): Promise<CodemodResult> {
const { dryRun = false } = options
const targetFiles = await getTargetFiles()
const numberOfFiles = Object.keys(targetFiles).length
if (numberOfFiles === 0) {
reporter.info(" No files found with zod imports")
return { filesScanned: 0, filesModified: 0, errors: 0 }
}
reporter.info(` Found ${numberOfFiles} files to process`)
let filesModified = 0
let errors = 0
for (const [filePath, content] of Object.entries(targetFiles)) {
try {
if (processFile(filePath, content, dryRun)) {
filesModified++
}
} catch (error) {
reporter.error(`✗ Error processing ${filePath}: ${error.message}`)
errors++
}
}
return { filesScanned: numberOfFiles, filesModified, errors }
}
/**
* Process a single file and replace zod imports
* @returns true if the file was modified, false otherwise
*/
function processFile(
filePath: string,
content: string,
dryRun: boolean
): boolean {
let modifiedContent = content
for (const { pattern, replacement } of REPLACEMENTS) {
modifiedContent = modifiedContent.replace(pattern, replacement)
}
if (modifiedContent === content) {
return false
}
if (dryRun) {
reporter.info(` Would update: ${filePath}`)
} else {
fs.writeFileSync(filePath, modifiedContent)
reporter.info(`✓ Updated: ${filePath}`)
}
return true
}
/**
* Find all TypeScript/JavaScript files that contain zod imports
* @returns Array of file paths with zod imports
*/
async function getTargetFiles(): Promise<Record<string, string>> {
try {
// Find TypeScript/JavaScript files, excluding build artifacts, dependencies, and src/admin
const files = await glob("**/*.{ts,js,tsx,jsx}", {
ignore: [
"**/node_modules/**",
"**/.git/**",
"**/dist/**",
"**/build/**",
"**/coverage/**",
"**/.medusa/**",
"**/src/admin/**",
],
nodir: true,
})
reporter.info(` Scanning ${files.length} files for zod imports...`)
const targetFiles: Record<string, string> = {}
let processedCount = 0
for (const file of files) {
try {
const content = fs.readFileSync(file, "utf8")
if (ZOD_IMPORT_PATTERN.test(content)) {
targetFiles[file] = content
}
processedCount++
if (processedCount % 100 === 0) {
process.stdout.write(
`\r Processed ${processedCount}/${files.length} files...`
)
}
} catch {
// Skip files that can't be read
continue
}
}
if (processedCount > 0) {
process.stdout.write(
`\r Processed ${processedCount} files. \n`
)
}
return targetFiles
} catch (error) {
reporter.error(`Error finding target files: ${error.message}`)
return {}
}
}

View File

@@ -0,0 +1,45 @@
/**
* Options for running a codemod
*/
export interface CodemodOptions {
/**
* Run the codemod without making actual file changes
*/
dryRun?: boolean
}
/**
* Result of running a codemod
*/
export interface CodemodResult {
/**
* Total number of files scanned for changes
*/
filesScanned: number
/**
* Number of files that were modified
*/
filesModified: number
/**
* Number of errors encountered during execution
*/
errors: number
}
/**
* A codemod that can be executed to transform code
*/
export interface Codemod {
/**
* Unique identifier for the codemod
*/
name: string
/**
* Human-readable description of what the codemod does
*/
description: string
/**
* Function that executes the codemod
*/
run: (options: CodemodOptions) => Promise<CodemodResult>
}

View File

@@ -2,6 +2,7 @@ import { setTelemetryEnabled } from "@medusajs/telemetry"
import { sync as existsSync } from "fs-exists-cached"
import path from "path"
import resolveCwd from "resolve-cwd"
import { getCodemod, listCodemods } from "./codemods/index"
import { newStarter } from "./commands/new"
import { didYouMean } from "./did-you-mean"
import reporter from "./reporter"
@@ -296,7 +297,7 @@ function buildLocalCommands(cli, isLocalProject) {
command: "plugin:build",
desc: "Build plugin source for publishing to a package registry",
handler: handlerP(
getCommandHandler("plugin/build", (args, cmd) => {
getCommandHandler("plugin/build", async (args, cmd) => {
process.env.NODE_ENV = process.env.NODE_ENV || `development`
cmd(args)
return new Promise((resolve) => {})
@@ -307,7 +308,7 @@ function buildLocalCommands(cli, isLocalProject) {
command: "plugin:develop",
desc: "Start plugin development process in watch mode. Changes will be re-published to the local packages registry",
handler: handlerP(
getCommandHandler("plugin/develop", (args, cmd) => {
getCommandHandler("plugin/develop", async (args, cmd) => {
process.env.NODE_ENV = process.env.NODE_ENV || `development`
cmd(args)
return new Promise(() => {})
@@ -318,7 +319,7 @@ function buildLocalCommands(cli, isLocalProject) {
command: "plugin:publish",
desc: "Publish the plugin to the local packages registry",
handler: handlerP(
getCommandHandler("plugin/publish", (args, cmd) => {
getCommandHandler("plugin/publish", async (args, cmd) => {
process.env.NODE_ENV = process.env.NODE_ENV || `development`
cmd(args)
return new Promise(() => {})
@@ -336,7 +337,7 @@ function buildLocalCommands(cli, isLocalProject) {
},
},
handler: handlerP(
getCommandHandler("plugin/add", (args, cmd) => {
getCommandHandler("plugin/add", async (args, cmd) => {
process.env.NODE_ENV = process.env.NODE_ENV || `development`
cmd(args)
return new Promise(() => {})
@@ -365,6 +366,62 @@ function buildLocalCommands(cli, isLocalProject) {
)
}),
})
.command({
command: `codemod <codemod-name>`,
desc: `Run automated code transformations`,
builder: (yargs) =>
yargs
.positional("codemod-name", {
type: "string",
describe: "Name of the codemod to run",
demandOption: true,
})
.option(`dry-run`, {
type: `boolean`,
description: `Preview changes without modifying files`,
default: false,
}),
handler: handlerP(async ({ codemodName, dryRun }) => {
const codemod = getCodemod(codemodName)
if (!codemod) {
const available = listCodemods()
reporter.error(`Unknown codemod: ${codemodName}`)
reporter.info(
`\nAvailable codemods:\n${available
.map((n) => ` - ${n}`)
.join("\n")}`
)
process.exit(1)
}
reporter.info(`Running codemod: ${codemod.name}`)
reporter.info(codemod.description)
if (dryRun) {
reporter.info(`\n DRY RUN MODE - No files will be modified\n`)
}
const result = await codemod.run({ dryRun })
reporter.info(`\n Summary:`)
reporter.info(` Files scanned: ${result.filesScanned}`)
reporter.info(` Files modified: ${result.filesModified}`)
reporter.info(` Errors: ${result.errors}`)
if (dryRun && result.filesModified > 0) {
reporter.info(`\n Run without --dry-run to apply changes`)
} else if (result.filesModified > 0) {
reporter.info(`\n Codemod completed successfully!`)
reporter.info(`\n Next steps:`)
reporter.info(` 1. Review changes: git diff`)
reporter.info(` 2. Run tests to verify`)
reporter.info(` 3. Commit if satisfied`)
} else {
reporter.info(`\n No modifications needed`)
}
}),
})
.command({
command: `develop`,
desc: `Start development server. Watches file and rebuilds when something changes`,
@@ -392,7 +449,7 @@ function buildLocalCommands(cli, isLocalProject) {
: `Set port. Defaults to ${defaultPort}`,
}),
handler: handlerP(
getCommandHandler(`develop`, (args, cmd) => {
getCommandHandler(`develop`, async (args, cmd) => {
process.env.NODE_ENV = process.env.NODE_ENV || `development`
cmd(args)
@@ -447,7 +504,7 @@ function buildLocalCommands(cli, isLocalProject) {
"Number of server processes in cluster mode or a percentage of cluster size (e.g., 25%).",
}),
handler: handlerP(
getCommandHandler(`start`, (args, cmd) => {
getCommandHandler(`start`, async (args, cmd) => {
process.env.NODE_ENV = process.env.NODE_ENV || `production`
cmd(args)
// Return an empty promise to prevent handlerP from exiting early.
@@ -468,7 +525,7 @@ function buildLocalCommands(cli, isLocalProject) {
"Only build the admin to serve it separately (outDir .medusa/admin)",
}),
handler: handlerP(
getCommandHandler(`build`, (args, cmd) => {
getCommandHandler(`build`, async (args, cmd) => {
process.env.NODE_ENV = process.env.NODE_ENV || `development`
cmd(args)
@@ -501,7 +558,7 @@ function buildLocalCommands(cli, isLocalProject) {
default: false,
}),
handler: handlerP(
getCommandHandler(`user`, (args, cmd) => {
getCommandHandler(`user`, async (args, cmd) => {
cmd(args)
// Return an empty promise to prevent handlerP from exiting early.
// The development server shouldn't ever exit until the user directly
@@ -514,7 +571,7 @@ function buildLocalCommands(cli, isLocalProject) {
command: `exec [file] [args..]`,
desc: `Run a function defined in a file.`,
handler: handlerP(
getCommandHandler(`exec`, (args, cmd) => {
getCommandHandler(`exec`, async (args, cmd) => {
cmd(args)
// Return an empty promise to prevent handlerP from exiting early.
// The development server shouldn't ever exit until the user directly