diff --git a/.changeset/bold-moons-hear.md b/.changeset/bold-moons-hear.md new file mode 100644 index 0000000000..108f8c4628 --- /dev/null +++ b/.changeset/bold-moons-hear.md @@ -0,0 +1,5 @@ +--- +"@medusajs/cli": patch +--- + +feat(medusa-cli): add codemod command + codemod for replacing zod imports diff --git a/packages/cli/medusa-cli/src/codemods/__tests__/index.test.ts b/packages/cli/medusa-cli/src/codemods/__tests__/index.test.ts new file mode 100644 index 0000000000..8f396d8ce3 --- /dev/null +++ b/packages/cli/medusa-cli/src/codemods/__tests__/index.test.ts @@ -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") + }) + }) +}) diff --git a/packages/cli/medusa-cli/src/codemods/__tests__/replace-zod-imports.test.ts b/packages/cli/medusa-cli/src/codemods/__tests__/replace-zod-imports.test.ts new file mode 100644 index 0000000000..b3c01eed74 --- /dev/null +++ b/packages/cli/medusa-cli/src/codemods/__tests__/replace-zod-imports.test.ts @@ -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) + }) + }) +}) diff --git a/packages/cli/medusa-cli/src/codemods/index.ts b/packages/cli/medusa-cli/src/codemods/index.ts new file mode 100644 index 0000000000..b5201ddf1e --- /dev/null +++ b/packages/cli/medusa-cli/src/codemods/index.ts @@ -0,0 +1,26 @@ +import type { Codemod } from "./types" +import replaceZodImports from "./replace-zod-imports" + +/** + * Registry of available codemods + */ +const CODEMODS: Record = { + "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) +} diff --git a/packages/cli/medusa-cli/src/codemods/replace-zod-imports.ts b/packages/cli/medusa-cli/src/codemods/replace-zod-imports.ts new file mode 100644 index 0000000000..958478eb07 --- /dev/null +++ b/packages/cli/medusa-cli/src/codemods/replace-zod-imports.ts @@ -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 { + 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> { + 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 = {} + 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 {} + } +} diff --git a/packages/cli/medusa-cli/src/codemods/types.ts b/packages/cli/medusa-cli/src/codemods/types.ts new file mode 100644 index 0000000000..d6b5fdec95 --- /dev/null +++ b/packages/cli/medusa-cli/src/codemods/types.ts @@ -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 +} diff --git a/packages/cli/medusa-cli/src/create-cli.ts b/packages/cli/medusa-cli/src/create-cli.ts index 7cd573219c..3ff6c56684 100644 --- a/packages/cli/medusa-cli/src/create-cli.ts +++ b/packages/cli/medusa-cli/src/create-cli.ts @@ -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 `, + 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