diff --git a/.changeset/seven-days-add.md b/.changeset/seven-days-add.md new file mode 100644 index 0000000000..55b1e6227e --- /dev/null +++ b/.changeset/seven-days-add.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +add previous version error suggestion diff --git a/packages/shadcn/src/utils/create-project.test.ts b/packages/shadcn/src/utils/create-project.test.ts index 7e56b6db01..d4403045b3 100644 --- a/packages/shadcn/src/utils/create-project.test.ts +++ b/packages/shadcn/src/utils/create-project.test.ts @@ -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) diff --git a/packages/shadcn/src/utils/get-package-manager.ts b/packages/shadcn/src/utils/get-package-manager.ts index 63b155ad0b..5492ef2e07 100644 --- a/packages/shadcn/src/utils/get-package-manager.ts +++ b/packages/shadcn/src/utils/get-package-manager.ts @@ -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 { 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) +} diff --git a/packages/shadcn/src/utils/handle-error.test.ts b/packages/shadcn/src/utils/handle-error.test.ts new file mode 100644 index 0000000000..28a8d603be --- /dev/null +++ b/packages/shadcn/src/utils/handle-error.test.ts @@ -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) + }) +}) diff --git a/packages/shadcn/src/utils/handle-error.ts b/packages/shadcn/src/utils/handle-error.ts index 6936032c65..33f48af361 100644 --- a/packages/shadcn/src/utils/handle-error.ts +++ b/packages/shadcn/src/utils/handle-error.ts @@ -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, "'\\''")}'` +} diff --git a/packages/shadcn/test/utils/get-package-manager.test.ts b/packages/shadcn/test/utils/get-package-manager.test.ts index c373b804af..0456b0fa4b 100644 --- a/packages/shadcn/test/utils/get-package-manager.test.ts +++ b/packages/shadcn/test/utils/get-package-manager.test.ts @@ -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") +})