fix(cli): suggest previous minor on errors (#10559)

This commit is contained in:
shadcn
2026-05-05 16:15:58 +04:00
committed by GitHub
parent 309d95017f
commit 28b3e5f360
6 changed files with 226 additions and 15 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
add previous version error suggestion

View File

@@ -1,3 +1,8 @@
import {
getPackageManager,
getPackageManagerFromUserAgent,
getPackageRunnerCommand,
} from "@/src/utils/get-package-manager"
import { spinner } from "@/src/utils/spinner"
import { execa } from "execa"
import fs from "fs-extra"
@@ -20,6 +25,8 @@ vi.mock("execa")
vi.mock("prompts")
vi.mock("@/src/utils/get-package-manager", () => ({
getPackageManager: vi.fn().mockResolvedValue("npm"),
getPackageManagerFromUserAgent: vi.fn(() => "npm"),
getPackageRunnerCommand: vi.fn(() => "npx"),
}))
vi.mock("@/src/utils/spinner")
vi.mock("@/src/utils/logger", () => ({
@@ -35,6 +42,9 @@ describe("createProject", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getPackageManager).mockResolvedValue("npm")
vi.mocked(getPackageManagerFromUserAgent).mockReturnValue("npm")
vi.mocked(getPackageRunnerCommand).mockReturnValue("npx")
// Reset all fs mocks
vi.mocked(fs.access).mockResolvedValue(undefined)

View File

@@ -1,11 +1,13 @@
import { detect } from "@antfu/ni"
export type PackageManager = "yarn" | "pnpm" | "bun" | "npm" | "deno"
export async function getPackageManager(
targetDir: string,
{ withFallback }: { withFallback?: boolean } = {
withFallback: false,
}
): Promise<"yarn" | "pnpm" | "bun" | "npm" | "deno"> {
): Promise<PackageManager> {
const packageManager = await detect({ programmatic: true, cwd: targetDir })
if (packageManager === "yarn@berry") return "yarn"
@@ -17,8 +19,12 @@ export async function getPackageManager(
}
// Fallback to user agent if not detected.
const userAgent = process.env.npm_config_user_agent || ""
return getPackageManagerFromUserAgent() ?? "npm"
}
export function getPackageManagerFromUserAgent(
userAgent = process.env.npm_config_user_agent || ""
): PackageManager | null {
if (userAgent.startsWith("yarn")) {
return "yarn"
}
@@ -31,15 +37,27 @@ export async function getPackageManager(
return "bun"
}
return "npm"
if (userAgent.startsWith("deno")) {
return "deno"
}
if (userAgent.startsWith("npm")) {
return "npm"
}
return null
}
export async function getPackageRunner(cwd: string) {
const packageManager = await getPackageManager(cwd)
export function getPackageRunnerCommand(packageManager: PackageManager | null) {
if (packageManager === "pnpm") return "pnpm dlx"
if (packageManager === "bun") return "bunx"
return "npx"
}
export async function getPackageRunner(cwd: string) {
const packageManager = await getPackageManager(cwd)
return getPackageRunnerCommand(packageManager)
}

View File

@@ -0,0 +1,100 @@
import { RegistryNotFoundError } from "@/src/registry/errors"
import {
getPreviousMinorCommand,
getPreviousMinorVersion,
handleError,
} from "@/src/utils/handle-error"
import { logger } from "@/src/utils/logger"
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest"
vi.mock("@/src/utils/highlighter", () => ({
highlighter: {
error: (value: string) => value,
info: (value: string) => value,
},
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
},
}))
describe("handleError", () => {
const originalArgv = process.argv
const originalUserAgent = process.env.npm_config_user_agent
const exit = vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit:${code}`)
})
beforeEach(() => {
vi.clearAllMocks()
process.argv = ["node", "shadcn", "add", "foo"]
process.env.npm_config_user_agent = "npm/10.0.0 node/v22"
})
afterEach(() => {
process.argv = originalArgv
if (originalUserAgent) {
process.env.npm_config_user_agent = originalUserAgent
} else {
delete process.env.npm_config_user_agent
}
})
afterAll(() => {
exit.mockRestore()
})
it("gets the previous minor version", () => {
expect(getPreviousMinorVersion("4.6.0")).toBe("4.5.0")
expect(getPreviousMinorVersion("4.6.3")).toBe("4.5.0")
expect(getPreviousMinorVersion("4.0.0")).toBeNull()
expect(getPreviousMinorVersion("latest")).toBeNull()
})
it("builds a previous minor command from the failed arguments", () => {
expect(getPreviousMinorCommand("4.6.0", ["add", "foo"])).toBe(
"npx shadcn@4.5.0 add foo"
)
})
it("uses the package runner from the user agent", () => {
process.env.npm_config_user_agent = "pnpm/10.0.0 npm/? node/v22"
expect(getPreviousMinorCommand("4.6.0", ["add", "foo"])).toBe(
"pnpm dlx shadcn@4.5.0 add foo"
)
})
it("quotes arguments that need shell escaping", () => {
expect(
getPreviousMinorCommand("4.6.0", ["add", "hello world", "it's-working"])
).toBe("npx shadcn@4.5.0 add 'hello world' 'it'\\''s-working'")
})
it("prints the previous minor command before exiting", () => {
expect(() => {
handleError(
new RegistryNotFoundError(
"http://localhost:4000/r/styles/new-york-v4/foo.json"
)
)
}).toThrow("process.exit:1")
expect(logger.error).toHaveBeenCalledWith(
"You can also try a previous version to see if that works:"
)
expect(logger.error).toHaveBeenCalledWith("npx shadcn@4.5.0 add foo")
expect(exit).toHaveBeenCalledWith(1)
})
})

View File

@@ -1,8 +1,14 @@
import { RegistryError } from "@/src/registry/errors"
import {
getPackageManagerFromUserAgent,
getPackageRunnerCommand,
} from "@/src/utils/get-package-manager"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { z } from "zod"
import packageJson from "../../package.json"
export function handleError(error: unknown) {
logger.break()
logger.error(
@@ -12,8 +18,7 @@ export function handleError(error: unknown) {
logger.error("")
if (typeof error === "string") {
logger.error(error)
logger.break()
process.exit(1)
exitWithPreviousVersionSuggestion()
}
if (error instanceof RegistryError) {
@@ -31,8 +36,7 @@ export function handleError(error: unknown) {
logger.error("\nSuggestion:")
logger.error(error.suggestion)
}
logger.break()
process.exit(1)
exitWithPreviousVersionSuggestion()
}
if (error instanceof z.ZodError) {
@@ -40,16 +44,68 @@ export function handleError(error: unknown) {
for (const [key, value] of Object.entries(error.flatten().fieldErrors)) {
logger.error(`- ${highlighter.info(key)}: ${value}`)
}
logger.break()
process.exit(1)
exitWithPreviousVersionSuggestion()
}
if (error instanceof Error) {
logger.error(error.message)
logger.break()
process.exit(1)
exitWithPreviousVersionSuggestion()
}
exitWithPreviousVersionSuggestion()
}
export function getPreviousMinorVersion(version: string) {
const match = version.match(/^(\d+)\.(\d+)\.\d+/)
if (!match) {
return null
}
const major = Number.parseInt(match[1], 10)
const minor = Number.parseInt(match[2], 10)
if (minor === 0) {
return null
}
return `${major}.${minor - 1}.0`
}
export function getPreviousMinorCommand(
version = packageJson.version,
args = process.argv.slice(2)
) {
const previousMinorVersion = getPreviousMinorVersion(version)
if (!previousMinorVersion) {
return null
}
const runner = getPackageRunnerCommand(getPackageManagerFromUserAgent())
return [...runner.split(" "), `shadcn@${previousMinorVersion}`, ...args]
.map(quoteShellArg)
.join(" ")
}
function exitWithPreviousVersionSuggestion() {
const command = getPreviousMinorCommand()
if (command) {
logger.error("")
logger.error("You can also try a previous version to see if that works:")
logger.error(command)
}
logger.break()
process.exit(1)
}
function quoteShellArg(value: string) {
if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(value)) {
return value
}
return `'${value.replace(/'/g, "'\\''")}'`
}

View File

@@ -1,7 +1,11 @@
import path from "path"
import { expect, test } from "vitest"
import { getPackageManager } from "../../src/utils/get-package-manager"
import {
getPackageManager,
getPackageManagerFromUserAgent,
getPackageRunnerCommand,
} from "../../src/utils/get-package-manager"
test("get package manager", async () => {
expect(
@@ -30,3 +34,21 @@ test("get package manager", async () => {
await getPackageManager(path.resolve(__dirname, "../fixtures/next"))
).toBe("pnpm")
})
test("get package manager from user agent", () => {
expect(getPackageManagerFromUserAgent("pnpm/10.0.0 npm/? node/v22")).toBe(
"pnpm"
)
expect(getPackageManagerFromUserAgent("bun/1.2.0 npm/? node/v22")).toBe(
"bun"
)
expect(getPackageManagerFromUserAgent("npm/10.0.0 node/v22")).toBe("npm")
expect(getPackageManagerFromUserAgent("")).toBeNull()
})
test("get package runner command", () => {
expect(getPackageRunnerCommand("pnpm")).toBe("pnpm dlx")
expect(getPackageRunnerCommand("bun")).toBe("bunx")
expect(getPackageRunnerCommand("npm")).toBe("npx")
expect(getPackageRunnerCommand(null)).toBe("npx")
})