feat(create-medusa-app): add support for pnpm and specifying package manager (#14443)

* feat(create-medusa-app): add support for pnpm and specifying package manager

* fixes

* add medusa command method

* add tests for package manager

* fix duplicate messages

* throw if chosen package manager is not available

* better package manager and version detector

* add debug message

* fix version detection

* fix for yarn v3

* fix migration command

* yarn v3 fix

* remove .yarn directory for non-yarn package managers

* run npm ci to validate npm installation

* fixes

* fixes

* remove corepack line

* remove if condition
This commit is contained in:
Shahed Nasser
2026-01-12 12:55:26 +02:00
committed by GitHub
parent 747d1393ae
commit 5f90cd0650
16 changed files with 1130 additions and 149 deletions

View File

@@ -0,0 +1,5 @@
---
"create-medusa-app": patch
---
feat(create-medusa-app): add support for pnpm and specifying package manager

View File

@@ -0,0 +1,14 @@
const defineJestConfig = require("../../../define_jest_config")
module.exports = defineJestConfig({
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
extensionsToTreatAsEsm: [".ts"],
testPathIgnorePatterns: [
"dist/",
"node_modules/",
"__fixtures__/",
"__mocks__/",
],
})

View File

@@ -9,8 +9,9 @@
"scripts": { "scripts": {
"dev": "node --loader ts-node/esm src/index.ts", "dev": "node --loader ts-node/esm src/index.ts",
"start": "node dist/index.js", "start": "node dist/index.js",
"build": "tsc", "build": "yarn run -T tsc",
"watch": "yarn run -T tsc --watch" "watch": "yarn run -T tsc --watch",
"test": "../../../node_modules/.bin/jest --passWithNoTests src"
}, },
"dependencies": { "dependencies": {
"@medusajs/deps": "2.12.5", "@medusajs/deps": "2.12.5",

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { program } from "commander" import { Option, program } from "commander"
import create from "./commands/create.js" import create from "./commands/create.js"
program program
@@ -42,6 +42,24 @@ program
"Show all logs of underlying commands. Useful for debugging.", "Show all logs of underlying commands. Useful for debugging.",
false false
) )
.addOption(
new Option("--use-npm", "Use npm as the package manager").conflicts([
"usePnpm",
"useYarn",
])
)
.addOption(
new Option("--use-yarn", "Use yarn as the package manager").conflicts([
"useNpm",
"usePnpm",
])
)
.addOption(
new Option("--use-pnpm", "Use pnpm as the package manager").conflicts([
"useNpm",
"useYarn",
])
)
.parse() .parse()
void create(program.args, program.opts()) void create(program.args, program.opts())

View File

@@ -0,0 +1,637 @@
import PackageManager from "../package-manager"
import ProcessManager from "../process-manager"
import execute from "../execute"
import { existsSync, rmSync } from "fs"
import logMessage from "../log-message"
// Mock dependencies
jest.mock("../execute")
jest.mock("fs")
jest.mock("../log-message")
const mockExecute = execute as jest.MockedFunction<typeof execute>
const mockExistsSync = existsSync as jest.MockedFunction<typeof existsSync>
const mockRmSync = rmSync as jest.MockedFunction<typeof rmSync>
const mockLogMessage = logMessage as jest.MockedFunction<typeof logMessage>
describe("PackageManager", () => {
let processManager: ProcessManager
let originalEnv: NodeJS.ProcessEnv
beforeEach(() => {
processManager = new ProcessManager()
originalEnv = { ...process.env }
jest.clearAllMocks()
})
afterEach(() => {
process.env = originalEnv
})
describe("constructor", () => {
it("should initialize with default options", () => {
const pm = new PackageManager(processManager)
expect(pm.getPackageManager()).toBeUndefined()
})
it("should set npm as chosen package manager when useNpm is true", () => {
const pm = new PackageManager(processManager, { useNpm: true })
expect(pm["chosenPackageManager"]).toBe("npm")
})
it("should set yarn as chosen package manager when useYarn is true", () => {
const pm = new PackageManager(processManager, { useYarn: true })
expect(pm["chosenPackageManager"]).toBe("yarn")
})
it("should set pnpm as chosen package manager when usePnpm is true", () => {
const pm = new PackageManager(processManager, { usePnpm: true })
expect(pm["chosenPackageManager"]).toBe("pnpm")
})
it("should respect verbose option", () => {
const pm = new PackageManager(processManager, { verbose: true })
expect(pm["verbose"]).toBe(true)
})
it("should prioritize npm over other options", () => {
const pm = new PackageManager(processManager, {
useNpm: true,
useYarn: true,
usePnpm: true,
})
expect(pm["chosenPackageManager"]).toBe("npm")
})
})
describe("detectFromUserAgent", () => {
it("should detect pnpm from user agent with version", () => {
process.env.npm_config_user_agent = "pnpm/8.0.0"
const pm = new PackageManager(processManager)
const result = pm["detectFromUserAgent"]()
expect(result.manager).toBe("pnpm")
expect(result.version).toBe("8.0.0")
})
it("should detect pnpm from pnpx with version", () => {
process.env.npm_config_user_agent = "pnpx/8.0.0"
const pm = new PackageManager(processManager)
const result = pm["detectFromUserAgent"]()
expect(result.manager).toBe("pnpm")
expect(result.version).toBe("8.0.0")
})
it("should detect yarn from user agent with version", () => {
process.env.npm_config_user_agent = "yarn/1.22.0"
const pm = new PackageManager(processManager)
const result = pm["detectFromUserAgent"]()
expect(result.manager).toBe("yarn")
expect(result.version).toBe("1.22.0")
})
it("should default to npm for unknown user agent", () => {
process.env.npm_config_user_agent = "some-unknown-manager/1.0.0"
const pm = new PackageManager(processManager)
const result = pm["detectFromUserAgent"]()
expect(result.manager).toBe("npm")
expect(result.version).toBeUndefined()
})
it("should default to npm when user agent is undefined", () => {
delete process.env.npm_config_user_agent
const pm = new PackageManager(processManager)
const result = pm["detectFromUserAgent"]()
expect(result.manager).toBe("npm")
expect(result.version).toBeUndefined()
})
})
describe("setPackageManager", () => {
it("should not run if package manager is already set", async () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "yarn"
await pm.setPackageManager({})
expect(mockExecute).not.toHaveBeenCalled()
})
it("should set chosen package manager if available", async () => {
mockExecute.mockResolvedValue({ stdout: "8.0.0", stderr: "" })
const pm = new PackageManager(processManager, { usePnpm: true })
await pm.setPackageManager({})
expect(pm.getPackageManager()).toBe("pnpm")
expect(await pm.getPackageManagerString()).toBe("pnpm@8.0.0")
expect(mockExecute).toHaveBeenCalledWith(
["pnpm -v", {}],
{ verbose: false }
)
})
it("should throw error when chosen package manager is not available", async () => {
mockExecute.mockRejectedValueOnce(new Error("Command not found"))
const pm = new PackageManager(processManager, { usePnpm: true })
await pm.setPackageManager({})
expect(mockLogMessage).toHaveBeenCalledWith({
type: "error",
message: expect.stringContaining('"pnpm" is not available'),
})
})
it("should detect from user agent when no package manager chosen", async () => {
process.env.npm_config_user_agent = "yarn/1.22.0"
const pm = new PackageManager(processManager)
await pm.setPackageManager({})
expect(pm.getPackageManager()).toBe("yarn")
expect(await pm.getPackageManagerString()).toBe("yarn@1.22.0")
// Detection message should not be logged in non-verbose mode
expect(mockLogMessage).not.toHaveBeenCalledWith({
type: "info",
message: expect.stringContaining('Using detected package manager "yarn"'),
})
// Should not call getVersion since version is in user agent
expect(mockExecute).not.toHaveBeenCalled()
})
it("should use detected package manager even without version in user agent", async () => {
mockExecute.mockResolvedValue({ stdout: "1.22.0", stderr: "" })
process.env.npm_config_user_agent = "yarn"
const pm = new PackageManager(processManager)
await pm.setPackageManager({})
expect(pm.getPackageManager()).toBe("yarn")
expect(await pm.getPackageManagerString()).toBe("yarn@1.22.0")
// Should call getVersion since version is not in user agent
expect(mockExecute).toHaveBeenCalledWith(["yarn -v", {}], {
verbose: false,
})
// Fallback message should not be logged in non-verbose mode
expect(mockLogMessage).not.toHaveBeenCalledWith({
type: "info",
message: expect.stringContaining("Falling back to"),
})
})
it("should log detection messages in verbose mode", async () => {
process.env.npm_config_user_agent = "yarn/1.22.0"
const pm = new PackageManager(processManager, { verbose: true })
await pm.setPackageManager({})
expect(pm.getPackageManager()).toBe("yarn")
expect(mockLogMessage).toHaveBeenCalledWith({
type: "info",
message: expect.stringContaining('Using detected package manager "yarn"'),
})
// Should not call getVersion since version is in user agent
expect(mockExecute).not.toHaveBeenCalled()
})
it("should log fallback messages in verbose mode when no version in user agent", async () => {
mockExecute.mockResolvedValue({ stdout: "1.22.0", stderr: "" })
process.env.npm_config_user_agent = "yarn"
const pm = new PackageManager(processManager, { verbose: true })
await pm.setPackageManager({})
expect(pm.getPackageManager()).toBe("yarn")
expect(mockLogMessage).toHaveBeenCalledWith({
type: "info",
message: expect.stringContaining("Falling back to yarn"),
})
// Should call getVersion to get the version
expect(mockExecute).toHaveBeenCalledWith(["yarn -v", {}], {
verbose: false,
})
})
})
describe("removeLockFiles", () => {
it("should not remove files if package manager is not set", async () => {
const pm = new PackageManager(processManager)
await pm.removeLockFiles("/test/path")
expect(mockExistsSync).not.toHaveBeenCalled()
expect(mockRmSync).not.toHaveBeenCalled()
})
it("should remove yarn.lock, pnpm-lock.yaml, and .yarn when using npm", async () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "npm"
mockExistsSync.mockReturnValue(true)
await pm.removeLockFiles("/test/path")
expect(mockExistsSync).toHaveBeenCalledWith("/test/path/yarn.lock")
expect(mockExistsSync).toHaveBeenCalledWith("/test/path/pnpm-lock.yaml")
expect(mockExistsSync).toHaveBeenCalledWith("/test/path/.yarn")
expect(mockRmSync).toHaveBeenCalledWith("/test/path/yarn.lock", {
force: true,
recursive: true,
})
expect(mockRmSync).toHaveBeenCalledWith("/test/path/pnpm-lock.yaml", {
force: true,
recursive: true,
})
expect(mockRmSync).toHaveBeenCalledWith("/test/path/.yarn", {
force: true,
recursive: true,
})
})
it("should remove package-lock.json and pnpm-lock.yaml when using yarn", async () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "yarn"
mockExistsSync.mockReturnValue(true)
await pm.removeLockFiles("/test/path")
expect(mockExistsSync).toHaveBeenCalledWith("/test/path/package-lock.json")
expect(mockExistsSync).toHaveBeenCalledWith("/test/path/pnpm-lock.yaml")
expect(mockRmSync).toHaveBeenCalledWith("/test/path/package-lock.json", {
force: true,
recursive: true,
})
expect(mockRmSync).toHaveBeenCalledWith("/test/path/pnpm-lock.yaml", {
force: true,
recursive: true,
})
})
it("should remove yarn.lock, package-lock.json, and .yarn when using pnpm", async () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "pnpm"
mockExistsSync.mockReturnValue(true)
await pm.removeLockFiles("/test/path")
expect(mockExistsSync).toHaveBeenCalledWith("/test/path/yarn.lock")
expect(mockExistsSync).toHaveBeenCalledWith("/test/path/package-lock.json")
expect(mockExistsSync).toHaveBeenCalledWith("/test/path/.yarn")
expect(mockRmSync).toHaveBeenCalledWith("/test/path/yarn.lock", {
force: true,
recursive: true,
})
expect(mockRmSync).toHaveBeenCalledWith("/test/path/package-lock.json", {
force: true,
recursive: true,
})
expect(mockRmSync).toHaveBeenCalledWith("/test/path/.yarn", {
force: true,
recursive: true,
})
})
it("should not remove files that don't exist", async () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "npm"
mockExistsSync.mockReturnValue(false)
await pm.removeLockFiles("/test/path")
expect(mockExistsSync).toHaveBeenCalled()
expect(mockRmSync).not.toHaveBeenCalled()
})
})
describe("installDependencies", () => {
it("should set package manager before installing if not set", async () => {
process.env.npm_config_user_agent = "yarn/1.22.0"
const pm = new PackageManager(processManager)
await pm.installDependencies({ cwd: "/test/path" })
expect(pm.getPackageManager()).toBe("yarn")
})
it("should remove lock files before installing", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
mockExistsSync.mockReturnValue(true)
const pm = new PackageManager(processManager, { useNpm: true })
pm["packageManager"] = "npm"
await pm.installDependencies({ cwd: "/test/path" })
expect(mockRmSync).toHaveBeenCalled()
})
it("should execute yarn command when using yarn", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
const pm = new PackageManager(processManager)
pm["packageManager"] = "yarn"
await pm.installDependencies({ cwd: "/test/path" })
expect(mockExecute).toHaveBeenCalledWith(
["yarn", { cwd: "/test/path" }],
{ verbose: false }
)
})
it("should execute pnpm install when using pnpm", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
const pm = new PackageManager(processManager)
pm["packageManager"] = "pnpm"
await pm.installDependencies({ cwd: "/test/path" })
expect(mockExecute).toHaveBeenCalledWith(
["pnpm install", { cwd: "/test/path" }],
{ verbose: false }
)
})
it("should execute npm install then npm ci when using npm", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
const pm = new PackageManager(processManager)
pm["packageManager"] = "npm"
await pm.installDependencies({ cwd: "/test/path" })
expect(mockExecute).toHaveBeenCalledWith(
["npm install", { cwd: "/test/path" }],
{ verbose: false }
)
expect(mockExecute).toHaveBeenCalledWith(
["npm ci", { cwd: "/test/path" }],
{ verbose: false }
)
expect(mockExecute).toHaveBeenCalledTimes(2)
})
it("should re-run npm install if npm ci fails", async () => {
mockExecute
.mockResolvedValueOnce({ stdout: "", stderr: "" }) // npm install succeeds
.mockRejectedValueOnce(new Error("npm ci failed")) // npm ci fails
.mockResolvedValueOnce({ stdout: "", stderr: "" }) // npm install retry succeeds
const pm = new PackageManager(processManager)
pm["packageManager"] = "npm"
await pm.installDependencies({ cwd: "/test/path" })
expect(mockExecute).toHaveBeenCalledWith(
["npm install", { cwd: "/test/path" }],
{ verbose: false }
)
expect(mockExecute).toHaveBeenCalledWith(
["npm ci", { cwd: "/test/path" }],
{ verbose: false }
)
// Second npm install call
expect(mockExecute).toHaveBeenCalledTimes(3)
})
it("should respect verbose option", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
const pm = new PackageManager(processManager, { verbose: true })
pm["packageManager"] = "yarn"
await pm.installDependencies({ cwd: "/test/path" })
expect(mockExecute).toHaveBeenCalledWith(
expect.anything(),
{ verbose: true }
)
})
})
describe("getCommandStr", () => {
it("should throw error if package manager is not set", () => {
const pm = new PackageManager(processManager)
expect(() => pm.getCommandStr("dev")).toThrow("Package manager not set")
})
it("should return yarn command format", () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "yarn"
expect(pm.getCommandStr("dev")).toBe("yarn dev")
})
it("should return pnpm command format", () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "pnpm"
expect(pm.getCommandStr("build")).toBe("pnpm build")
})
it("should return npm command format with 'run'", () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "npm"
expect(pm.getCommandStr("test")).toBe("npm run test")
})
})
describe("runCommand", () => {
it("should set package manager before running if not set", async () => {
process.env.npm_config_user_agent = "yarn/1.22.0"
const pm = new PackageManager(processManager)
await pm.runCommand("dev", { cwd: "/test/path" })
expect(pm.getPackageManager()).toBe("yarn")
})
it("should execute command with correct format for yarn", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
const pm = new PackageManager(processManager)
pm["packageManager"] = "yarn"
await pm.runCommand("dev", { cwd: "/test/path" })
expect(mockExecute).toHaveBeenCalledWith(
["yarn dev", { cwd: "/test/path" }],
{ verbose: false }
)
})
it("should execute command with correct format for npm", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
const pm = new PackageManager(processManager)
pm["packageManager"] = "npm"
await pm.runCommand("build", { cwd: "/test/path" })
expect(mockExecute).toHaveBeenCalledWith(
["npm run build", { cwd: "/test/path" }],
{ verbose: false }
)
})
it("should pass through verbose options", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
const pm = new PackageManager(processManager, { verbose: true })
pm["packageManager"] = "yarn"
await pm.runCommand("dev", { cwd: "/test/path" }, { needOutput: true })
expect(mockExecute).toHaveBeenCalledWith(
expect.anything(),
{ verbose: true, needOutput: true }
)
})
it("should return execution result", async () => {
const result = { stdout: "success", stderr: "" }
mockExecute.mockResolvedValue(result)
const pm = new PackageManager(processManager)
pm["packageManager"] = "yarn"
const output = await pm.runCommand("dev", {})
expect(output).toEqual(result)
})
})
describe("runMedusaCommand", () => {
it("should set package manager before running if not set", async () => {
process.env.npm_config_user_agent = "yarn/1.22.0"
const pm = new PackageManager(processManager)
await pm.runMedusaCommand("migrate", { cwd: "/test/path" })
expect(pm.getPackageManager()).toBe("yarn")
})
it("should execute yarn medusa command for yarn", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
const pm = new PackageManager(processManager)
pm["packageManager"] = "yarn"
await pm.runMedusaCommand("migrate", { cwd: "/test/path" })
expect(mockExecute).toHaveBeenCalledWith(
["yarn medusa migrate", { cwd: "/test/path" }],
{ verbose: false }
)
})
it("should execute pnpm medusa command for pnpm", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
const pm = new PackageManager(processManager)
pm["packageManager"] = "pnpm"
await pm.runMedusaCommand("seed", { cwd: "/test/path" })
expect(mockExecute).toHaveBeenCalledWith(
["pnpm medusa seed", { cwd: "/test/path" }],
{ verbose: false }
)
})
it("should execute npx medusa command for npm", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
const pm = new PackageManager(processManager)
pm["packageManager"] = "npm"
await pm.runMedusaCommand("start", { cwd: "/test/path" })
expect(mockExecute).toHaveBeenCalledWith(
["npx medusa start", { cwd: "/test/path" }],
{ verbose: false }
)
})
it("should pass through verbose options", async () => {
mockExecute.mockResolvedValue({ stdout: "", stderr: "" })
const pm = new PackageManager(processManager, { verbose: true })
pm["packageManager"] = "yarn"
await pm.runMedusaCommand("migrate", { cwd: "/test/path" }, { needOutput: true })
expect(mockExecute).toHaveBeenCalledWith(
expect.anything(),
{ verbose: true, needOutput: true }
)
})
it("should return execution result", async () => {
const result = { stdout: "migration complete", stderr: "" }
mockExecute.mockResolvedValue(result)
const pm = new PackageManager(processManager)
pm["packageManager"] = "yarn"
const output = await pm.runMedusaCommand("migrate", {})
expect(output).toEqual(result)
})
})
describe("getPackageManager", () => {
it("should return undefined when not set", () => {
const pm = new PackageManager(processManager)
expect(pm.getPackageManager()).toBeUndefined()
})
it("should return the current package manager", () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "yarn"
expect(pm.getPackageManager()).toBe("yarn")
})
})
describe("getPackageManagerString", () => {
it("should return undefined when version is not set", async () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "yarn"
expect(await pm.getPackageManagerString()).toBeUndefined()
})
it("should return packageManager@version format", async () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "yarn"
pm["packageManagerVersion"] = "4.9.0"
expect(await pm.getPackageManagerString()).toBe("yarn@4.9.0")
})
it("should work with pnpm", async () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "pnpm"
pm["packageManagerVersion"] = "8.15.0"
expect(await pm.getPackageManagerString()).toBe("pnpm@8.15.0")
})
it("should work with npm", async () => {
const pm = new PackageManager(processManager)
pm["packageManager"] = "npm"
pm["packageManagerVersion"] = "10.0.0"
expect(await pm.getPackageManagerString()).toBe("npm@10.0.0")
})
it("should call setPackageManager if package manager is not set", async () => {
process.env.npm_config_user_agent = "yarn/1.22.0"
const pm = new PackageManager(processManager)
const result = await pm.getPackageManagerString()
expect(pm.getPackageManager()).toBe("yarn")
expect(result).toBe("yarn@1.22.0")
})
})
})

View File

@@ -4,6 +4,7 @@ import path from "path"
import { isAbortError } from "./create-abort-controller.js" import { isAbortError } from "./create-abort-controller.js"
import execute from "./execute.js" import execute from "./execute.js"
import logMessage from "./log-message.js" import logMessage from "./log-message.js"
import { execFileSync } from "child_process"
type CloneRepoOptions = { type CloneRepoOptions = {
directoryName?: string directoryName?: string
@@ -80,13 +81,37 @@ export async function runCloneRepo({
} }
function deleteGitDirectory(projectDirectory: string) { function deleteGitDirectory(projectDirectory: string) {
fs.rmSync(path.join(projectDirectory, ".git"), { try {
recursive: true, fs.rmSync(path.join(projectDirectory, ".git"), {
force: true, recursive: true,
}) force: true,
})
} catch (error) {
deleteWithCommand(projectDirectory, ".git")
}
fs.rmSync(path.join(projectDirectory, ".github"), { try {
recursive: true, fs.rmSync(path.join(projectDirectory, ".github"), {
force: true, recursive: true,
}) force: true,
})
} catch (error) {
deleteWithCommand(projectDirectory, ".github")
}
}
/**
* Useful for deleting directories when fs methods fail (e.g., with Yarn v3)
*/
function deleteWithCommand(projectDirectory: string, dirName: string) {
const dirPath = path.normalize(path.join(projectDirectory, dirName))
if (!fs.existsSync(dirPath)) {
return
}
if (process.platform === "win32") {
execFileSync("cmd", ["/c", "rmdir", "/s", "/q", dirPath])
} else {
execFileSync("rm", ["-rf", dirPath])
}
} }

View File

@@ -4,12 +4,12 @@ import { getAbortError } from "./create-abort-controller.js"
const promiseExec = util.promisify(exec) const promiseExec = util.promisify(exec)
type ExecuteOptions = { export type ExecuteResult = {
stdout?: string stdout?: string
stderr?: string stderr?: string
} }
type VerboseOptions = { export type VerboseOptions = {
verbose?: boolean verbose?: boolean
// Since spawn doesn't allow us to both retrieve the // Since spawn doesn't allow us to both retrieve the
// output and output it live without using events, // output and output it live without using events,
@@ -26,21 +26,27 @@ type SpawnParams = [string, SpawnSyncOptions]
const execute = async ( const execute = async (
command: SpawnParams | PromiseExecParams, command: SpawnParams | PromiseExecParams,
{ verbose = false, needOutput = false }: VerboseOptions { verbose = false, needOutput = false }: VerboseOptions
): Promise<ExecuteOptions> => { ): Promise<ExecuteResult> => {
if (verbose) { if (verbose) {
const [commandStr, options] = command as SpawnParams const [commandStr, options] = command as SpawnParams
const childProcess = spawnSync(commandStr, { const childProcess = spawnSync(commandStr, {
...options, ...options,
shell: true, shell: true,
stdio: needOutput ? stdio: needOutput
"pipe" : ? "pipe"
[process.stdin, process.stdout, process.stderr], : [process.stdin, process.stdout, process.stderr],
env: {
...process.env,
...(options.env || {}),
},
}) })
if (childProcess.error || childProcess.status !== 0) { if (childProcess.error || childProcess.status !== 0) {
throw childProcess.error || throw (
childProcess.stderr?.toString() || childProcess.error ||
childProcess.stderr?.toString() ||
`${commandStr} failed with status ${childProcess.status}` `${commandStr} failed with status ${childProcess.status}`
)
} }
if ( if (
@@ -61,7 +67,14 @@ const execute = async (
stderr: childProcess.stderr?.toString() || "", stderr: childProcess.stderr?.toString() || "",
} }
} else { } else {
const childProcess = await promiseExec(...(command as PromiseExecParams)) const [commandStr, options] = command as PromiseExecParams
const childProcess = await promiseExec(commandStr, {
...options,
env: {
...process.env,
...(options?.env || {}),
},
})
return { return {
stdout: childProcess.stdout as string, stdout: childProcess.stdout as string,

View File

@@ -7,8 +7,8 @@ import { isAbortError } from "./create-abort-controller.js"
import execute from "./execute.js" import execute from "./execute.js"
import { displayFactBox, FactBoxOptions } from "./facts.js" import { displayFactBox, FactBoxOptions } from "./facts.js"
import logMessage from "./log-message.js" import logMessage from "./log-message.js"
import ProcessManager from "./process-manager.js"
import { updatePackageVersions } from "./update-package-versions.js" import { updatePackageVersions } from "./update-package-versions.js"
import PackageManager from "./package-manager.js"
const NEXTJS_REPO = "https://github.com/medusajs/nextjs-starter-medusa" const NEXTJS_REPO = "https://github.com/medusajs/nextjs-starter-medusa"
const NEXTJS_BRANCH = "main" const NEXTJS_BRANCH = "main"
@@ -31,7 +31,7 @@ type InstallOptions = {
abortController?: AbortController abortController?: AbortController
factBoxOptions: FactBoxOptions factBoxOptions: FactBoxOptions
verbose?: boolean verbose?: boolean
processManager: ProcessManager packageManager: PackageManager
version?: string version?: string
} }
@@ -40,7 +40,7 @@ export async function installNextjsStarter({
abortController, abortController,
factBoxOptions, factBoxOptions,
verbose = false, verbose = false,
processManager, packageManager,
version, version,
}: InstallOptions): Promise<string> { }: InstallOptions): Promise<string> {
factBoxOptions.interval = displayFactBox({ factBoxOptions.interval = displayFactBox({
@@ -83,20 +83,7 @@ export async function installNextjsStarter({
signal: abortController?.signal, signal: abortController?.signal,
cwd: nextjsDirectory, cwd: nextjsDirectory,
} }
await processManager.runProcess({ await packageManager.installDependencies(execOptions)
process: async () => {
try {
await execute([`yarn`, execOptions], { verbose })
} catch (e) {
// yarn isn't available
// use npm
await execute([`npm install`, execOptions], {
verbose,
})
}
},
ignoreERESOLVE: true,
})
} catch (e) { } catch (e) {
if (isAbortError(e)) { if (isAbortError(e)) {
process.exit() process.exit()
@@ -130,14 +117,17 @@ type StartOptions = {
directory: string directory: string
abortController?: AbortController abortController?: AbortController
verbose?: boolean verbose?: boolean
packageManager: PackageManager
} }
export function startNextjsStarter({ export function startNextjsStarter({
directory, directory,
abortController, abortController,
verbose = false, verbose = false,
packageManager,
}: StartOptions) { }: StartOptions) {
const childProcess = exec(`npm run dev`, { const command = packageManager.getCommandStr(`dev`)
const childProcess = exec(command, {
cwd: directory, cwd: directory,
signal: abortController?.signal, signal: abortController?.signal,
}) })

View File

@@ -1,14 +1,113 @@
import execute from "./execute.js" import path from "path"
import execute, { VerboseOptions } from "./execute.js"
import logMessage from "./log-message.js"
import ProcessManager from "./process-manager.js" import ProcessManager from "./process-manager.js"
import { existsSync, rmSync } from "fs"
export type PackageManagerType = "npm" | "yarn" | "pnpm"
type PackageManagerOptions = {
verbose?: boolean
useNpm?: boolean
usePnpm?: boolean
useYarn?: boolean
}
export default class PackageManager { export default class PackageManager {
protected packageManager?: "npm" | "yarn" protected packageManager?: PackageManagerType
protected packageManagerVersion?: string
protected processManager: ProcessManager protected processManager: ProcessManager
protected verbose protected verbose
protected chosenPackageManager?: PackageManagerType
constructor(processManager: ProcessManager, verbose = false) { constructor(
processManager: ProcessManager,
options: PackageManagerOptions = {}
) {
this.processManager = processManager this.processManager = processManager
this.verbose = verbose this.verbose = options.verbose || false
if (options.useNpm) {
this.chosenPackageManager = "npm"
} else if (options.usePnpm) {
this.chosenPackageManager = "pnpm"
} else if (options.useYarn) {
this.chosenPackageManager = "yarn"
}
}
private detectFromUserAgent(): {
manager: PackageManagerType
version?: string
} {
const userAgent = process.env.npm_config_user_agent
if (!userAgent) {
return { manager: "npm" }
}
// Extract package manager and version (e.g., "yarn/4.9.0" -> ["yarn", "4.9.0"])
const match = userAgent.match(/(pnpm|pnpx|yarn|npm)\/(\d+\.\d+\.\d+)/)
if (match) {
const [, manager, version] = match
if (this.verbose) {
logMessage({
type: "info",
message: `Detected from user agent: ${manager}@${version}`,
})
}
// pnpx is an alias for pnpm
if (manager === "pnpx") {
return { manager: "pnpm", version }
}
return { manager: manager as PackageManagerType, version }
}
// Fallback detection without version
if (userAgent.includes("pnpm") || userAgent.includes("pnpx")) {
return { manager: "pnpm" }
}
if (userAgent.includes("yarn")) {
return { manager: "yarn" }
}
return { manager: "npm" }
}
private async getVersion(
pm: PackageManagerType,
execOptions: Record<string, unknown>
): Promise<string | undefined> {
const commands: Record<PackageManagerType, string> = {
yarn: "yarn -v",
pnpm: "pnpm -v",
npm: "npm -v",
}
try {
const result = await execute([commands[pm], execOptions], {
verbose: false,
})
const version = result.stdout?.trim()
if (this.verbose) {
logMessage({
type: "info",
message: `Detected ${pm} version: ${version}`,
})
}
return version
} catch {
if (this.verbose) {
logMessage({
type: "info",
message: `Failed to get version for package manager: ${pm}`,
})
}
return undefined
}
} }
async setPackageManager(execOptions: Record<string, unknown>): Promise<void> { async setPackageManager(execOptions: Record<string, unknown>): Promise<void> {
@@ -16,38 +115,127 @@ export default class PackageManager {
return return
} }
// check whether yarn is available // check whether package manager is available and get version
await this.processManager.runProcess({ await this.processManager.runProcess({
process: async () => { process: async () => {
try { if (this.chosenPackageManager) {
await execute([`yarn -v`, execOptions], { verbose: this.verbose }) const version = await this.getVersion(
// yarn is available this.chosenPackageManager,
this.packageManager = "yarn" execOptions
} catch (e) { )
// yarn isn't available
// use npm if (version) {
this.packageManager = "npm" this.packageManager = this.chosenPackageManager
// Store version if we don't have it from user agent
if (!this.packageManagerVersion) {
this.packageManagerVersion = version
}
return
}
// Error logs exit the process, so command execution will stop here
logMessage({
type: "error",
message: `The specified package manager "${this.chosenPackageManager}" is not available. Please install it or choose another package manager.`,
})
}
const detectedResult = this.detectFromUserAgent()
// fallback to npm if detection fails
this.packageManager = detectedResult.manager || "npm"
this.packageManagerVersion = detectedResult.version
if (!this.packageManagerVersion) {
// get version for the detected package manager (or npm fallback)
this.packageManagerVersion = await this.getVersion(
this.packageManager,
execOptions
)
if (this.verbose) {
logMessage({
type: "info",
message: `Falling back to ${this.packageManager} as the package manager.`,
})
}
} else {
if (this.verbose) {
logMessage({
type: "info",
message: `Using detected package manager "${this.packageManager}".`,
})
}
} }
}, },
ignoreERESOLVE: true, ignoreERESOLVE: true,
}) })
} }
async installDependencies( async removeLockFiles(directory: string): Promise<void> {
execOptions: Record<string, unknown>, const lockFiles: Record<PackageManagerType, string[]> = {
) { npm: ["yarn.lock", "pnpm-lock.yaml", ".yarn"],
yarn: ["package-lock.json", "pnpm-lock.yaml"],
pnpm: ["yarn.lock", "package-lock.json", ".yarn"],
}
if (!this.packageManager) {
return
}
const filesToRemove = lockFiles[this.packageManager] || []
for (const file of filesToRemove) {
const filePath = path.join(directory, file)
if (existsSync(filePath)) {
rmSync(filePath, {
force: true,
recursive: true,
})
}
}
}
async installDependencies(execOptions: Record<string, unknown>) {
if (!this.packageManager) { if (!this.packageManager) {
await this.setPackageManager(execOptions) await this.setPackageManager(execOptions)
} }
const command = this.packageManager === "yarn" ? // Remove lock files from other package managers
`yarn` : `npm install` if (execOptions.cwd && typeof execOptions.cwd === "string") {
await this.removeLockFiles(execOptions.cwd)
}
const commands: Record<PackageManagerType, string> = {
yarn: "yarn",
pnpm: "pnpm install",
npm: "npm install",
}
const command = commands[this.packageManager || "npm"]
await this.processManager.runProcess({ await this.processManager.runProcess({
process: async () => { process: async () => {
await execute([command, execOptions], { await execute([command, execOptions], {
verbose: this.verbose verbose: this.verbose,
}) })
// For npm, run npm ci after npm install to validate installation
if (this.packageManager === "npm") {
try {
await execute(["npm ci", execOptions], {
verbose: this.verbose,
})
} catch (error) {
// If npm ci fails, re-run npm install
if (this.verbose) {
logMessage({
type: "info",
message: "npm ci validation failed, re-running npm install...",
})
}
await execute(["npm install", execOptions], {
verbose: this.verbose,
})
}
}
}, },
ignoreERESOLVE: true, ignoreERESOLVE: true,
}) })
@@ -56,6 +244,7 @@ export default class PackageManager {
async runCommand( async runCommand(
command: string, command: string,
execOptions: Record<string, unknown>, execOptions: Record<string, unknown>,
verboseOptions: VerboseOptions = {}
) { ) {
if (!this.packageManager) { if (!this.packageManager) {
await this.setPackageManager(execOptions) await this.setPackageManager(execOptions)
@@ -63,25 +252,83 @@ export default class PackageManager {
const commandStr = this.getCommandStr(command) const commandStr = this.getCommandStr(command)
await this.processManager.runProcess({ return await this.processManager.runProcess({
process: async () => { process: async () => {
await execute([commandStr, execOptions], { return await execute([commandStr, execOptions], {
verbose: this.verbose verbose: this.verbose,
}) ...verboseOptions,
}, })
ignoreERESOLVE: true, },
}) ignoreERESOLVE: true,
})
} }
getCommandStr( async runMedusaCommand(
command: string, command: string,
): string { execOptions: Record<string, unknown>,
verboseOptions: VerboseOptions = {}
) {
if (!this.packageManager) {
await this.setPackageManager(execOptions)
}
const formats: Record<PackageManagerType, string> = {
yarn: `yarn medusa ${command}`,
pnpm: `pnpm medusa ${command}`,
npm: `npx medusa ${command}`,
}
const commandStr = formats[this.packageManager || "npm"]
return await this.processManager.runProcess({
process: async () => {
return await execute([commandStr, execOptions], {
verbose: this.verbose,
...verboseOptions,
})
},
ignoreERESOLVE: true,
})
}
getCommandStr(command: string): string {
if (!this.packageManager) { if (!this.packageManager) {
throw new Error("Package manager not set") throw new Error("Package manager not set")
} }
return this.packageManager === "yarn" const formats: Record<PackageManagerType, string> = {
? `yarn ${command}` yarn: `yarn ${command}`,
: `npm run ${command}` pnpm: `pnpm ${command}`,
npm: `npm run ${command}`,
}
return formats[this.packageManager]
} }
}
getPackageManager(): PackageManagerType | undefined {
return this.packageManager
}
async getPackageManagerString(): Promise<string | undefined> {
if (!this.packageManager) {
await this.setPackageManager({})
}
if (!this.packageManagerVersion) {
if (this.verbose) {
logMessage({
type: "info",
message: `No version detected for package manager: ${this.packageManager}`,
})
}
return undefined
}
const result = `${this.packageManager}@${this.packageManagerVersion}`
if (this.verbose) {
logMessage({
type: "info",
message: `Package manager string: ${result}`,
})
}
return result
}
}

View File

@@ -1,7 +1,7 @@
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
import { Ora } from "ora" import { Ora } from "ora"
import execute from "./execute.js" import { ExecuteResult } from "./execute.js"
import { EOL } from "os" import { EOL } from "os"
import { displayFactBox, FactBoxOptions } from "./facts.js" import { displayFactBox, FactBoxOptions } from "./facts.js"
import ProcessManager from "./process-manager.js" import ProcessManager from "./process-manager.js"
@@ -95,6 +95,12 @@ async function preparePlugin({
// Update name // Update name
packageJson.name = projectName packageJson.name = projectName
// Add packageManager field to ensure consistent version usage
const packageManagerString = await packageManager.getPackageManagerString()
if (packageManagerString) {
packageJson.packageManager = packageManagerString
}
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)) fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2))
factBoxOptions.interval = displayFactBox({ factBoxOptions.interval = displayFactBox({
@@ -162,6 +168,12 @@ async function prepareProject({
// Update name // Update name
packageJson.name = projectName packageJson.name = projectName
// Add packageManager field to ensure consistent version usage
const packageManagerString = await packageManager.getPackageManagerString()
if (packageManagerString) {
packageJson.packageManager = packageManagerString
}
// Update medusa dependencies versions // Update medusa dependencies versions
if (version) { if (version) {
updatePackageVersions(packageJson, version) updatePackageVersions(packageJson, version)
@@ -210,59 +222,59 @@ async function prepareProject({
}) })
// run migrations // run migrations
await processManager.runProcess({ const migrationExecResult = await packageManager.runMedusaCommand(
process: async () => { "db:migrate",
const proc = await execute(["npx medusa db:migrate", npxOptions], { npxOptions,
verbose, {
needOutput: true, verbose,
}) needOutput: true,
}
)
if (client) { if (client) {
// check the migrations table is in the database // check the migrations table is in the database
// to ensure that migrations ran // to ensure that migrations ran
let errorOccurred = false let errorOccurred = false
try { try {
const migrations = await client.query( const migrations = await client.query(
`SELECT * FROM "mikro_orm_migrations"` `SELECT count(tablename) from pg_tables WHERE tablename = 'mikro_orm_migrations'`
) )
errorOccurred = migrations.rowCount == 0 errorOccurred = migrations.rowCount == 0
} catch (e) { } catch (e) {
// avoid error thrown if the migrations table // avoid error thrown if the migrations table
// doesn't exist // doesn't exist
errorOccurred = true errorOccurred = true
} }
// ensure that migrations actually ran in case of an uncaught error // ensure that migrations actually ran in case of an uncaught error
if (errorOccurred && (proc.stderr || proc.stdout)) { if (
throw new Error( errorOccurred &&
`An error occurred while running migrations: ${ (migrationExecResult.stderr || migrationExecResult.stdout)
proc.stderr || proc.stdout ) {
}` throw new Error(
) `An error occurred while running migrations: ${
} migrationExecResult.stderr || migrationExecResult.stdout
} }`
}, )
}) }
}
factBoxOptions.interval = displayFactBox({ factBoxOptions.interval = displayFactBox({
...factBoxOptions, ...factBoxOptions,
message: "Ran Migrations", message: "Ran Migrations",
}) })
await processManager.runProcess({ const userExecResult = (await packageManager.runMedusaCommand(
process: async () => { `user -e ${ADMIN_EMAIL} --invite`,
const proc = await execute( npxOptions,
[`npx medusa user -e ${ADMIN_EMAIL} --invite`, npxOptions], { verbose, needOutput: true }
{ verbose, needOutput: true } )) as ExecuteResult
)
// get invite token from stdout // get invite token from stdout
const match = (proc.stdout as string).match( const match = (userExecResult.stdout as string).match(
/Invite token: (?<token>.+)/ /Invite token: (?<token>.+)/
) )
inviteToken = match?.groups?.token inviteToken = match?.groups?.token
},
})
// TODO for now we just seed the default data // TODO for now we just seed the default data
// we should add onboarding seeding again if it makes // we should add onboarding seeding again if it makes

View File

@@ -16,8 +16,8 @@ export default class ProcessManager {
} }
onTerminated(fn: () => Promise<void> | void) { onTerminated(fn: () => Promise<void> | void) {
process.on("SIGTERM", () => fn()) process.on("SIGTERM", async () => fn())
process.on("SIGINT", () => fn()) process.on("SIGINT", async () => fn())
} }
addInterval(interval: NodeJS.Timeout) { addInterval(interval: NodeJS.Timeout) {
@@ -34,7 +34,7 @@ export default class ProcessManager {
do { do {
++retries ++retries
try { try {
await process() return await process()
} catch (error) { } catch (error) {
if ( if (
typeof error === "object" && typeof error === "object" &&

View File

@@ -17,6 +17,9 @@ export interface ProjectOptions {
verbose?: boolean verbose?: boolean
plugin?: boolean plugin?: boolean
version?: string version?: string
useNpm?: boolean
usePnpm?: boolean
useYarn?: boolean
} }
export interface ProjectCreator { export interface ProjectCreator {
@@ -42,7 +45,12 @@ export abstract class BaseProjectCreator {
) { ) {
this.spinner = ora() this.spinner = ora()
this.processManager = new ProcessManager() this.processManager = new ProcessManager()
this.packageManager = new PackageManager(this.processManager) this.packageManager = new PackageManager(this.processManager, {
verbose: options.verbose,
useNpm: options.useNpm,
usePnpm: options.usePnpm,
useYarn: options.useYarn,
})
this.abortController = createAbortController(this.processManager) this.abortController = createAbortController(this.processManager)
this.projectName = projectName this.projectName = projectName
const basePath = const basePath =

View File

@@ -3,7 +3,6 @@ import boxen from "boxen"
import chalk from "chalk" import chalk from "chalk"
import { emojify } from "node-emoji" import { emojify } from "node-emoji"
import { EOL } from "os" import { EOL } from "os"
import slugifyType from "slugify"
import { runCloneRepo } from "../clone-repo.js" import { runCloneRepo } from "../clone-repo.js"
import { isAbortError } from "../create-abort-controller.js" import { isAbortError } from "../create-abort-controller.js"
import { displayFactBox } from "../facts.js" import { displayFactBox } from "../facts.js"
@@ -92,7 +91,7 @@ export class PluginProjectCreator
logMessage({ logMessage({
message: boxen( message: boxen(
chalk.green( chalk.green(
`Change to the \`${this.projectName}\` directory to explore your Medusa plugin.${EOL}${EOL}Check out the Medusa plugin documentation to start your development:${EOL}${EOL}https://docs.medusajs.com/${EOL}${EOL}Star us on GitHub if you like what we're building:${EOL}${EOL}https://github.com/medusajs/medusa/stargazers` `Change to the \`${this.projectName}\` directory to explore your Medusa plugin.${EOL}${EOL}Check out the Medusa plugin documentation to start your development:${EOL}${EOL}https://docs.medusajs.com/learn/fundamentals/plugins${EOL}${EOL}Star us on GitHub if you like what we're building:${EOL}${EOL}https://github.com/medusajs/medusa/stargazers`
), ),
{ {
titleAlignment: "center", titleAlignment: "center",

View File

@@ -75,32 +75,28 @@ export class MedusaProjectCreator
title: "Setting up project...", title: "Setting up project...",
}) })
try { await runCloneRepo({
await runCloneRepo({ projectName: this.projectPath,
projectName: this.projectPath, repoUrl: this.options.repoUrl ?? "",
repoUrl: this.options.repoUrl ?? "", abortController: this.abortController,
spinner: this.spinner,
verbose: this.options.verbose,
})
this.factBoxOptions.interval = displayFactBox({
...this.factBoxOptions,
message: "Created project directory",
})
if (installNextjs) {
this.nextjsDirectory = await installNextjsStarter({
directoryName: this.projectPath,
abortController: this.abortController, abortController: this.abortController,
spinner: this.spinner, factBoxOptions: this.factBoxOptions,
verbose: this.options.verbose, verbose: this.options.verbose,
packageManager: this.packageManager,
version: this.options.version,
}) })
this.factBoxOptions.interval = displayFactBox({
...this.factBoxOptions,
message: "Created project directory",
})
if (installNextjs) {
this.nextjsDirectory = await installNextjsStarter({
directoryName: this.projectPath,
abortController: this.abortController,
factBoxOptions: this.factBoxOptions,
verbose: this.options.verbose,
processManager: this.processManager,
version: this.options.version,
})
}
} catch (e) {
throw e
} }
} }
@@ -177,6 +173,7 @@ export class MedusaProjectCreator
startMedusa({ startMedusa({
directory: this.projectPath, directory: this.projectPath,
abortController: this.abortController, abortController: this.abortController,
packageManager: this.packageManager,
}) })
if (this.nextjsDirectory) { if (this.nextjsDirectory) {
@@ -184,6 +181,7 @@ export class MedusaProjectCreator
directory: this.nextjsDirectory, directory: this.nextjsDirectory,
abortController: this.abortController, abortController: this.abortController,
verbose: this.options.verbose, verbose: this.options.verbose,
packageManager: this.packageManager,
}) })
} }
@@ -195,13 +193,13 @@ export class MedusaProjectCreator
private async openBrowser(): Promise<void> { private async openBrowser(): Promise<void> {
await waitOn({ await waitOn({
resources: ["http://localhost:9000/health"], resources: ["http://localhost:9000/health"],
}).then(async () => { }).then(async () =>
open( open(
this.inviteToken this.inviteToken
? `http://localhost:9000/app/invite?token=${this.inviteToken}&first_run=true` ? `http://localhost:9000/app/invite?token=${this.inviteToken}&first_run=true`
: "http://localhost:9000/app" : "http://localhost:9000/app"
) )
}) )
} }
private handleError(e: Error): void { private handleError(e: Error): void {

View File

@@ -1,12 +1,19 @@
import { exec } from "child_process" import { exec } from "child_process"
import PackageManager from "./package-manager.js"
type StartOptions = { type StartOptions = {
directory: string directory: string
abortController?: AbortController abortController?: AbortController
packageManager: PackageManager
} }
export default ({ directory, abortController }: StartOptions) => { export default ({
const childProcess = exec(`npm run dev`, { directory,
abortController,
packageManager,
}: StartOptions) => {
const command = packageManager.getCommandStr(`dev`)
const childProcess = exec(command, {
cwd: directory, cwd: directory,
signal: abortController?.signal, signal: abortController?.signal,
env: { env: {

View File

@@ -10,6 +10,13 @@
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": ["src"], "include": ["src"],
"exclude": [
"dist",
"src/**/__tests__",
"src/**/__mocks__",
"src/**/__fixtures__",
"node_modules"
],
"ts-node": { "ts-node": {
"esm": true, "esm": true,
"experimentalSpecifierResolution": "node", "experimentalSpecifierResolution": "node",