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": {
"dev": "node --loader ts-node/esm src/index.ts",
"start": "node dist/index.js",
"build": "tsc",
"watch": "yarn run -T tsc --watch"
"build": "yarn run -T tsc",
"watch": "yarn run -T tsc --watch",
"test": "../../../node_modules/.bin/jest --passWithNoTests src"
},
"dependencies": {
"@medusajs/deps": "2.12.5",

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { program } from "commander"
import { Option, program } from "commander"
import create from "./commands/create.js"
program
@@ -42,6 +42,24 @@ program
"Show all logs of underlying commands. Useful for debugging.",
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()
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 execute from "./execute.js"
import logMessage from "./log-message.js"
import { execFileSync } from "child_process"
type CloneRepoOptions = {
directoryName?: string
@@ -80,13 +81,37 @@ export async function runCloneRepo({
}
function deleteGitDirectory(projectDirectory: string) {
try {
fs.rmSync(path.join(projectDirectory, ".git"), {
recursive: true,
force: true,
})
} catch (error) {
deleteWithCommand(projectDirectory, ".git")
}
try {
fs.rmSync(path.join(projectDirectory, ".github"), {
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)
type ExecuteOptions = {
export type ExecuteResult = {
stdout?: string
stderr?: string
}
type VerboseOptions = {
export type VerboseOptions = {
verbose?: boolean
// Since spawn doesn't allow us to both retrieve the
// output and output it live without using events,
@@ -26,21 +26,27 @@ type SpawnParams = [string, SpawnSyncOptions]
const execute = async (
command: SpawnParams | PromiseExecParams,
{ verbose = false, needOutput = false }: VerboseOptions
): Promise<ExecuteOptions> => {
): Promise<ExecuteResult> => {
if (verbose) {
const [commandStr, options] = command as SpawnParams
const childProcess = spawnSync(commandStr, {
...options,
shell: true,
stdio: needOutput ?
"pipe" :
[process.stdin, process.stdout, process.stderr],
stdio: needOutput
? "pipe"
: [process.stdin, process.stdout, process.stderr],
env: {
...process.env,
...(options.env || {}),
},
})
if (childProcess.error || childProcess.status !== 0) {
throw childProcess.error ||
throw (
childProcess.error ||
childProcess.stderr?.toString() ||
`${commandStr} failed with status ${childProcess.status}`
)
}
if (
@@ -61,7 +67,14 @@ const execute = async (
stderr: childProcess.stderr?.toString() || "",
}
} 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 {
stdout: childProcess.stdout as string,

View File

@@ -7,8 +7,8 @@ import { isAbortError } from "./create-abort-controller.js"
import execute from "./execute.js"
import { displayFactBox, FactBoxOptions } from "./facts.js"
import logMessage from "./log-message.js"
import ProcessManager from "./process-manager.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_BRANCH = "main"
@@ -31,7 +31,7 @@ type InstallOptions = {
abortController?: AbortController
factBoxOptions: FactBoxOptions
verbose?: boolean
processManager: ProcessManager
packageManager: PackageManager
version?: string
}
@@ -40,7 +40,7 @@ export async function installNextjsStarter({
abortController,
factBoxOptions,
verbose = false,
processManager,
packageManager,
version,
}: InstallOptions): Promise<string> {
factBoxOptions.interval = displayFactBox({
@@ -83,20 +83,7 @@ export async function installNextjsStarter({
signal: abortController?.signal,
cwd: nextjsDirectory,
}
await processManager.runProcess({
process: async () => {
try {
await execute([`yarn`, execOptions], { verbose })
} catch (e) {
// yarn isn't available
// use npm
await execute([`npm install`, execOptions], {
verbose,
})
}
},
ignoreERESOLVE: true,
})
await packageManager.installDependencies(execOptions)
} catch (e) {
if (isAbortError(e)) {
process.exit()
@@ -130,14 +117,17 @@ type StartOptions = {
directory: string
abortController?: AbortController
verbose?: boolean
packageManager: PackageManager
}
export function startNextjsStarter({
directory,
abortController,
verbose = false,
packageManager,
}: StartOptions) {
const childProcess = exec(`npm run dev`, {
const command = packageManager.getCommandStr(`dev`)
const childProcess = exec(command, {
cwd: directory,
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 { existsSync, rmSync } from "fs"
export type PackageManagerType = "npm" | "yarn" | "pnpm"
type PackageManagerOptions = {
verbose?: boolean
useNpm?: boolean
usePnpm?: boolean
useYarn?: boolean
}
export default class PackageManager {
protected packageManager?: "npm" | "yarn"
protected packageManager?: PackageManagerType
protected packageManagerVersion?: string
protected processManager: ProcessManager
protected verbose
protected chosenPackageManager?: PackageManagerType
constructor(processManager: ProcessManager, verbose = false) {
constructor(
processManager: ProcessManager,
options: PackageManagerOptions = {}
) {
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> {
@@ -16,38 +115,127 @@ export default class PackageManager {
return
}
// check whether yarn is available
// check whether package manager is available and get version
await this.processManager.runProcess({
process: async () => {
try {
await execute([`yarn -v`, execOptions], { verbose: this.verbose })
// yarn is available
this.packageManager = "yarn"
} catch (e) {
// yarn isn't available
// use npm
this.packageManager = "npm"
if (this.chosenPackageManager) {
const version = await this.getVersion(
this.chosenPackageManager,
execOptions
)
if (version) {
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,
})
}
async installDependencies(
execOptions: Record<string, unknown>,
) {
async removeLockFiles(directory: string): Promise<void> {
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) {
await this.setPackageManager(execOptions)
}
const command = this.packageManager === "yarn" ?
`yarn` : `npm install`
// Remove lock files from other package managers
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({
process: async () => {
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,
})
@@ -56,6 +244,7 @@ export default class PackageManager {
async runCommand(
command: string,
execOptions: Record<string, unknown>,
verboseOptions: VerboseOptions = {}
) {
if (!this.packageManager) {
await this.setPackageManager(execOptions)
@@ -63,25 +252,83 @@ export default class PackageManager {
const commandStr = this.getCommandStr(command)
await this.processManager.runProcess({
return await this.processManager.runProcess({
process: async () => {
await execute([commandStr, execOptions], {
verbose: this.verbose
return await execute([commandStr, execOptions], {
verbose: this.verbose,
...verboseOptions,
})
},
ignoreERESOLVE: true,
})
}
getCommandStr(
async runMedusaCommand(
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) {
throw new Error("Package manager not set")
}
return this.packageManager === "yarn"
? `yarn ${command}`
: `npm run ${command}`
const formats: Record<PackageManagerType, string> = {
yarn: `yarn ${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 path from "path"
import { Ora } from "ora"
import execute from "./execute.js"
import { ExecuteResult } from "./execute.js"
import { EOL } from "os"
import { displayFactBox, FactBoxOptions } from "./facts.js"
import ProcessManager from "./process-manager.js"
@@ -95,6 +95,12 @@ async function preparePlugin({
// Update name
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))
factBoxOptions.interval = displayFactBox({
@@ -162,6 +168,12 @@ async function prepareProject({
// Update name
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
if (version) {
updatePackageVersions(packageJson, version)
@@ -210,12 +222,14 @@ async function prepareProject({
})
// run migrations
await processManager.runProcess({
process: async () => {
const proc = await execute(["npx medusa db:migrate", npxOptions], {
const migrationExecResult = await packageManager.runMedusaCommand(
"db:migrate",
npxOptions,
{
verbose,
needOutput: true,
})
}
)
if (client) {
// check the migrations table is in the database
@@ -223,7 +237,7 @@ async function prepareProject({
let errorOccurred = false
try {
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
} catch (e) {
@@ -233,36 +247,34 @@ async function prepareProject({
}
// ensure that migrations actually ran in case of an uncaught error
if (errorOccurred && (proc.stderr || proc.stdout)) {
if (
errorOccurred &&
(migrationExecResult.stderr || migrationExecResult.stdout)
) {
throw new Error(
`An error occurred while running migrations: ${
proc.stderr || proc.stdout
migrationExecResult.stderr || migrationExecResult.stdout
}`
)
}
}
},
})
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
message: "Ran Migrations",
})
await processManager.runProcess({
process: async () => {
const proc = await execute(
[`npx medusa user -e ${ADMIN_EMAIL} --invite`, npxOptions],
const userExecResult = (await packageManager.runMedusaCommand(
`user -e ${ADMIN_EMAIL} --invite`,
npxOptions,
{ verbose, needOutput: true }
)
)) as ExecuteResult
// get invite token from stdout
const match = (proc.stdout as string).match(
const match = (userExecResult.stdout as string).match(
/Invite token: (?<token>.+)/
)
inviteToken = match?.groups?.token
},
})
// TODO for now we just seed the default data
// we should add onboarding seeding again if it makes

View File

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

View File

@@ -17,6 +17,9 @@ export interface ProjectOptions {
verbose?: boolean
plugin?: boolean
version?: string
useNpm?: boolean
usePnpm?: boolean
useYarn?: boolean
}
export interface ProjectCreator {
@@ -42,7 +45,12 @@ export abstract class BaseProjectCreator {
) {
this.spinner = ora()
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.projectName = projectName
const basePath =

View File

@@ -3,7 +3,6 @@ import boxen from "boxen"
import chalk from "chalk"
import { emojify } from "node-emoji"
import { EOL } from "os"
import slugifyType from "slugify"
import { runCloneRepo } from "../clone-repo.js"
import { isAbortError } from "../create-abort-controller.js"
import { displayFactBox } from "../facts.js"
@@ -92,7 +91,7 @@ export class PluginProjectCreator
logMessage({
message: boxen(
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",

View File

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

View File

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

View File

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