From 40ab22fded686fe277851e33f4c5a9ad1bbb22ca Mon Sep 17 00:00:00 2001 From: shadcn Date: Fri, 27 Feb 2026 11:45:30 +0400 Subject: [PATCH] fix: bug in registries --- packages/shadcn/src/commands/add.ts | 7 +- .../shadcn/src/utils/dry-run-formatter.ts | 45 ++++--- packages/shadcn/test/utils/registries.test.ts | 123 ++++++++++++++++++ 3 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 packages/shadcn/test/utils/registries.test.ts diff --git a/packages/shadcn/src/commands/add.ts b/packages/shadcn/src/commands/add.ts index f73c34b80c..f6e39c1784 100644 --- a/packages/shadcn/src/commands/add.ts +++ b/packages/shadcn/src/commands/add.ts @@ -70,6 +70,8 @@ export const add = new Command() await loadEnvFiles(options.cwd) + const isDryRun = options.dryRun || options.diff || options.view + let initialConfig = await getConfig(options.cwd) if (!initialConfig) { initialConfig = createConfig({ @@ -103,8 +105,6 @@ export const add = new Command() itemType !== "registry:style" && itemType !== "registry:base" - const isDryRun = options.dryRun || options.diff || options.view - if (isUniversalRegistryItem(registryItem) && !isDryRun) { await addComponents(components, initialConfig, options) return @@ -263,13 +263,14 @@ export const add = new Command() config, { silent: options.silent || hasNewRegistries, + writeFile: !isDryRun, } ) config = updatedConfig // Dry-run mode: preview changes without writing files. // --diff and --view imply --dry-run. - if (options.dryRun || options.diff || options.view) { + if (isDryRun) { const dryRunSpinner = spinner("Resolving items.", { silent: options.silent, }).start() diff --git a/packages/shadcn/src/utils/dry-run-formatter.ts b/packages/shadcn/src/utils/dry-run-formatter.ts index a267e4f376..812fd9481e 100644 --- a/packages/shadcn/src/utils/dry-run-formatter.ts +++ b/packages/shadcn/src/utils/dry-run-formatter.ts @@ -26,7 +26,9 @@ function colorAction(action: DryRunFile["action"] | "update") { // Format the shared header line. function formatHeader(componentNames: string[]) { - return `${bold("┌")} ${bold(`shadcn add ${componentNames.join(", ")}`)} ${dim("(dry run)")}` + return `${bold("┌")} ${bold(`shadcn add ${componentNames.join(", ")}`)} ${dim( + "(dry run)" + )}` } // Check if a CSS path matches a filter. @@ -92,7 +94,9 @@ function formatSummaryOutput(result: DryRunResult, componentNames: string[]) { if (overwriteCount > 0) { lines.push( yellow( - `⚠ ${overwriteCount} ${overwriteCount === 1 ? "file" : "files"} will be overwritten.` + `⚠ ${overwriteCount} ${ + overwriteCount === 1 ? "file" : "files" + } will be overwritten.` ) ) lines.push(dim("│")) @@ -107,7 +111,9 @@ function formatSummaryOutput(result: DryRunResult, componentNames: string[]) { } if (result.dependencies.length > 0) { summaryParts.push( - `${result.dependencies.length} ${result.dependencies.length === 1 ? "dep" : "deps"}` + `${result.dependencies.length} ${ + result.dependencies.length === 1 ? "dep" : "deps" + }` ) } if (result.css?.cssVarsCount) { @@ -154,7 +160,9 @@ function formatDiffOutput( if (cssMatch && result.css) { lines.push( - `${dim("├")} ${bold(result.css.path)} ${dim("(")}${colorAction(result.css.action)}${dim(")")}` + `${dim("├")} ${bold(result.css.path)} ${dim("(")}${colorAction( + result.css.action + )}${dim(")")}` ) if (result.css.action === "create" || !result.css.existingContent) { @@ -183,7 +191,9 @@ function formatDiffOutput( // Format a single file's diff block. function formatFileDiff(file: DryRunFile, lines: string[]) { lines.push( - `${dim("├")} ${bold(file.path)} ${dim("(")}${colorAction(file.action)}${dim(")")}` + `${dim("├")} ${bold(file.path)} ${dim("(")}${colorAction(file.action)}${dim( + ")" + )}` ) if (file.action === "skip") { @@ -225,7 +235,9 @@ function formatViewOutput( for (const file of filesToView) { const contentLines = file.content.split("\n") lines.push( - `${dim("├")} ${bold(file.path)} ${dim("(")}${colorAction(file.action)}${dim(")")} ${dim(`${contentLines.length} lines`)}` + `${dim("├")} ${bold(file.path)} ${dim("(")}${colorAction( + file.action + )}${dim(")")} ${dim(`${contentLines.length} lines`)}` ) pushContentBox(lines, contentLines) lines.push(dim("│")) @@ -234,7 +246,9 @@ function formatViewOutput( if (cssMatch && result.css) { const contentLines = result.css.content.split("\n") lines.push( - `${dim("├")} ${bold(result.css.path)} ${dim("(")}${colorAction(result.css.action)}${dim(")")} ${dim(`${contentLines.length} lines`)}` + `${dim("├")} ${bold(result.css.path)} ${dim("(")}${colorAction( + result.css.action + )}${dim(")")} ${dim(`${contentLines.length} lines`)}` ) pushContentBox(lines, contentLines) lines.push(dim("│")) @@ -285,8 +299,8 @@ function formatFilesSection(result: DryRunResult, lines: string[]) { file.action === "create" ? green : file.action === "overwrite" - ? yellow - : dim + ? yellow + : dim const pathStr = file.action === "skip" ? dim(file.path) : file.path @@ -319,7 +333,9 @@ function formatCssSection(result: DryRunResult, lines: string[]) { if (result.css.cssVarsCount > 0) { lines.push( - `${dim("│")} ${green("+")} ${result.css.cssVarsCount} CSS variables added to ${cyan(result.css.path)}` + `${dim("│")} ${green("+")} ${ + result.css.cssVarsCount + } CSS variables added to ${cyan(result.css.path)}` ) } else { lines.push(`${dim("│")} ${green("+")} Updated ${cyan(result.css.path)}`) @@ -416,10 +432,7 @@ function computeUnifiedDiff( return [dim(" No changes.")] } - const output: string[] = [ - dim(`--- a/${filePath}`), - dim(`+++ b/${filePath}`), - ] + const output: string[] = [dim(`--- a/${filePath}`), dim(`+++ b/${filePath}`)] // Use the actual new file lines for display. const newLines = newStr.split("\n") @@ -439,7 +452,9 @@ function computeUnifiedDiff( output.push( cyan( - `@@ -${hunk.oldStart},${contextCount + removedCount} +${hunk.newStart},${contextCount + addedCount} @@` + `@@ -${hunk.oldStart},${contextCount + removedCount} +${ + hunk.newStart + },${contextCount + addedCount} @@` ) ) diff --git a/packages/shadcn/test/utils/registries.test.ts b/packages/shadcn/test/utils/registries.test.ts new file mode 100644 index 0000000000..6b64b3588b --- /dev/null +++ b/packages/shadcn/test/utils/registries.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, test, vi } from "vitest" + +import type { Config } from "../../src/utils/get-config" + +// Mock dependencies. +vi.mock("../../src/registry/namespaces", () => ({ + resolveRegistryNamespaces: vi.fn().mockResolvedValue(["@foo"]), +})) + +vi.mock("../../src/registry/api", () => ({ + getRegistriesIndex: vi.fn().mockResolvedValue({ + "@foo": "https://foo.com/r/{name}.json", + }), +})) + +vi.mock("../../src/utils/spinner", () => ({ + spinner: vi.fn().mockReturnValue({ + start: vi.fn().mockReturnValue({ + succeed: vi.fn(), + fail: vi.fn(), + stop: vi.fn(), + }), + }), +})) + +vi.mock("fs-extra", () => ({ + default: { + writeFile: vi.fn().mockResolvedValue(undefined), + }, +})) + +import { ensureRegistriesInConfig } from "../../src/utils/registries" +import fs from "fs-extra" + +afterEach(() => { + vi.clearAllMocks() +}) + +const baseConfig: Config = { + $schema: "", + style: "new-york", + tailwind: { + config: "", + css: "", + baseColor: "", + cssVariables: true, + prefix: "", + }, + rsc: false, + tsx: true, + aliases: { + components: "@/components", + utils: "@/lib/utils", + ui: "@/components/ui", + lib: "@/lib", + hooks: "@/hooks", + }, + registries: {}, + resolvedPaths: { + cwd: "/tmp/test-project", + tailwindConfig: "", + tailwindCss: "", + utils: "", + components: "", + lib: "", + hooks: "", + ui: "", + }, +} + +describe("ensureRegistriesInConfig", () => { + test("does not write to disk when writeFile is false", async () => { + const { config, newRegistries } = await ensureRegistriesInConfig( + ["@foo/bar"], + baseConfig, + { writeFile: false } + ) + + // Should still return the updated config with new registries. + expect(newRegistries).toEqual(["@foo"]) + expect(config.registries?.["@foo"]).toBe( + "https://foo.com/r/{name}.json" + ) + + // Should NOT have written to disk. + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + test("writes to disk when writeFile is true", async () => { + await ensureRegistriesInConfig(["@foo/bar"], baseConfig, { + writeFile: true, + }) + + expect(fs.writeFile).toHaveBeenCalledTimes(1) + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining("components.json"), + expect.any(String), + "utf-8" + ) + }) + + test("writes to disk by default (writeFile not specified)", async () => { + await ensureRegistriesInConfig(["@foo/bar"], baseConfig) + + expect(fs.writeFile).toHaveBeenCalledTimes(1) + }) + + test("does not write when no new registries are found", async () => { + const configWithRegistry: Config = { + ...baseConfig, + registries: { + "@foo": "https://foo.com/r/{name}.json", + }, + } + + await ensureRegistriesInConfig(["@foo/bar"], configWithRegistry, { + writeFile: true, + }) + + // No new registries, so no write. + expect(fs.writeFile).not.toHaveBeenCalled() + }) +})