diff --git a/apps/v4/package.json b/apps/v4/package.json index 902b16f3aac..a9ddc18a033 100644 --- a/apps/v4/package.json +++ b/apps/v4/package.json @@ -90,7 +90,7 @@ "rehype-pretty-code": "^0.14.1", "rimraf": "^6.0.1", "server-only": "^0.0.1", - "shadcn": "4.17.0", + "shadcn": "4.18.0", "shiki": "^3.23.0", "sonner": "^2.0.0", "streamdown": "^2.5.0", diff --git a/packages/shadcn/CHANGELOG.md b/packages/shadcn/CHANGELOG.md index e3bc05e8287..a252b34becf 100644 --- a/packages/shadcn/CHANGELOG.md +++ b/packages/shadcn/CHANGELOG.md @@ -1,5 +1,21 @@ # shadcn +## 4.18.0 + +### Minor Changes + +- [#11501](https://github.com/shadcn-ui/ui/pull/11501) [`aef1cdca54e8da689351cdddf959342909e45e76`](https://github.com/shadcn-ui/ui/commit/aef1cdca54e8da689351cdddf959342909e45e76) Thanks [@shadcn](https://github.com/shadcn)! - merge registries from package.json and components.json, and support adding registries to package.json when components.json is not present + +### Patch Changes + +- [#11500](https://github.com/shadcn-ui/ui/pull/11500) [`e66b99b14dd9c54afc434dbf5a702f170b1153b0`](https://github.com/shadcn-ui/ui/commit/e66b99b14dd9c54afc434dbf5a702f170b1153b0) Thanks [@shadcn](https://github.com/shadcn)! - Skip unreadable directories during file scans instead of failing with `EACCES`. + +- [#11504](https://github.com/shadcn-ui/ui/pull/11504) [`9f4e3ff26025d16a243ea03cc891c734c4cf0b59`](https://github.com/shadcn-ui/ui/commit/9f4e3ff26025d16a243ea03cc891c734c4cf0b59) Thanks [@shadcn](https://github.com/shadcn)! - Skip unreadable directories when resolving monorepo targets. + +- [#11502](https://github.com/shadcn-ui/ui/pull/11502) [`87d71b3629c34f3c38a353a211ec8591c1ff1721`](https://github.com/shadcn-ui/ui/commit/87d71b3629c34f3c38a353a211ec8591c1ff1721) Thanks [@shadcn](https://github.com/shadcn)! - resolve registries declared in package.json when adding components. `shadcn add`, `search`, `view` and `init` now resolve registries from package.json in memory without persisting them to components.json + +- [#9248](https://github.com/shadcn-ui/ui/pull/9248) [`03c45b822e60195796dfd3d2fcf7c223ff4ece86`](https://github.com/shadcn-ui/ui/commit/03c45b822e60195796dfd3d2fcf7c223ff4ece86) Thanks [@Grafikart](https://github.com/Grafikart)! - Fix shadcn for projects with unreadable permission files + ## 4.17.0 ### Minor Changes diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index 9c5c179e17b..6256c914abd 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -1,6 +1,6 @@ { "name": "shadcn", - "version": "4.17.0", + "version": "4.18.0", "description": "Add components to your apps.", "publishConfig": { "access": "public" diff --git a/packages/shadcn/src/commands/init.test.ts b/packages/shadcn/src/commands/init.test.ts index db3b4f05d71..3cbdd64fd32 100644 --- a/packages/shadcn/src/commands/init.test.ts +++ b/packages/shadcn/src/commands/init.test.ts @@ -192,7 +192,12 @@ describe("runInit", () => { createProjectConfig(projectCwd) ) vi.mocked(ensureRegistriesInConfig).mockImplementation( - async (_components, config) => ({ config, newRegistries: [] }) + async (_components, config) => ({ + config, + newRegistries: [], + discoveredRegistries: {}, + packageJsonRegistries: {}, + }) ) vi.mocked(addComponents).mockResolvedValue(undefined) }) diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index 10040c8bce4..eaad6e938b4 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -712,17 +712,20 @@ export async function runInit( // Ensure registries are configured for the components we're about to add. const fullConfigForRegistry = await resolveConfigPaths(options.cwd, config) - const { config: configWithRegistries } = await ensureRegistriesInConfig( - components, - fullConfigForRegistry, - { + const { discoveredRegistries, packageJsonRegistries } = + await ensureRegistriesInConfig(components, fullConfigForRegistry, { silent: true, - } - ) + writeFile: false, + }) - // Update config with any new registries found. - if (configWithRegistries.registries) { - config.registries = configWithRegistries.registries + // Update config with registries discovered from the registries index. + // Registries declared in package.json are resolved in memory below and + // never persisted to components.json. + if (Object.keys(discoveredRegistries).length > 0) { + config.registries = { + ...config.registries, + ...discoveredRegistries, + } } const componentSpinner = spinner(`Writing components.json.`).start() @@ -775,6 +778,16 @@ export async function runInit( // Propagate design settings to workspace components.json files. const fullConfig = await resolveConfigPaths(options.cwd, config) + + // Include package.json-declared registries for installation. These are + // resolved in memory and stay out of the components.json we just wrote. + if (Object.keys(packageJsonRegistries).length > 0) { + fullConfig.registries = { + ...fullConfig.registries, + ...packageJsonRegistries, + } + } + const workspaceConfig = await getWorkspaceConfig(fullConfig) if (workspaceConfig) { const designSettings: Record = {} diff --git a/packages/shadcn/src/commands/registry/add.test.ts b/packages/shadcn/src/commands/registry/add.test.ts index 5727222c298..a0c06f961de 100644 --- a/packages/shadcn/src/commands/registry/add.test.ts +++ b/packages/shadcn/src/commands/registry/add.test.ts @@ -1,6 +1,9 @@ +import { tmpdir } from "os" +import path from "path" +import fs from "fs-extra" import { describe, expect, it } from "vitest" -import { parseRegistryArg } from "./add" +import { addRegistriesToConfig, parseRegistryArg } from "./add" describe("parseRegistryArg", () => { it("should parse namespace without URL", () => { @@ -56,3 +59,156 @@ describe("parseRegistryArg", () => { ).toThrow("must start with @") }) }) + +describe("addRegistriesToConfig", () => { + it("should write registries to components.json when it exists", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const componentsJsonFile = path.join(tempDir, "components.json") + + await fs.writeJson(componentsJsonFile, { style: "new-york" }) + + try { + await addRegistriesToConfig( + ["@acme=https://acme.com/r/{name}.json"], + tempDir, + { silent: true } + ) + + const config = await fs.readJson(componentsJsonFile) + expect(config).toEqual({ + style: "new-york", + registries: { + "@acme": "https://acme.com/r/{name}.json", + }, + }) + } finally { + await fs.rm(tempDir, { recursive: true }) + } + }) + + it("should prefer components.json over package.json when both exist", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const componentsJsonFile = path.join(tempDir, "components.json") + const packageJsonFile = path.join(tempDir, "package.json") + + await fs.writeJson(componentsJsonFile, { style: "new-york" }) + await fs.writeJson(packageJsonFile, { name: "test-package" }) + + try { + await addRegistriesToConfig( + ["@acme=https://acme.com/r/{name}.json"], + tempDir, + { silent: true } + ) + + const componentsJson = await fs.readJson(componentsJsonFile) + const packageJson = await fs.readJson(packageJsonFile) + expect(componentsJson.registries).toEqual({ + "@acme": "https://acme.com/r/{name}.json", + }) + expect(packageJson.registries).toBeUndefined() + } finally { + await fs.rm(tempDir, { recursive: true }) + } + }) + + it("should write registries to package.json when components.json does not exist", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const packageJsonFile = path.join(tempDir, "package.json") + + await fs.writeJson(packageJsonFile, { + name: "test-package", + version: "1.0.0", + }) + + try { + await addRegistriesToConfig( + ["@acme=https://acme.com/r/{name}.json"], + tempDir, + { silent: true } + ) + + const packageJson = await fs.readJson(packageJsonFile) + expect(packageJson).toEqual({ + name: "test-package", + version: "1.0.0", + registries: { + "@acme": "https://acme.com/r/{name}.json", + }, + }) + } finally { + await fs.rm(tempDir, { recursive: true }) + } + }) + + it("should preserve existing registries when adding to package.json", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const packageJsonFile = path.join(tempDir, "package.json") + + await fs.writeJson(packageJsonFile, { + name: "test-package", + registries: { + "@existing": "https://existing.com/r/{name}.json", + }, + }) + + try { + await addRegistriesToConfig( + ["@acme=https://acme.com/r/{name}.json"], + tempDir, + { silent: true } + ) + + const packageJson = await fs.readJson(packageJsonFile) + expect(packageJson.registries).toEqual({ + "@existing": "https://existing.com/r/{name}.json", + "@acme": "https://acme.com/r/{name}.json", + }) + } finally { + await fs.rm(tempDir, { recursive: true }) + } + }) + + it("should skip registries already configured in package.json", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const packageJsonFile = path.join(tempDir, "package.json") + + await fs.writeJson(packageJsonFile, { + name: "test-package", + registries: { + "@acme": "https://existing.com/r/{name}.json", + }, + }) + + try { + await addRegistriesToConfig( + ["@acme=https://new.com/r/{name}.json"], + tempDir, + { silent: true } + ) + + const packageJson = await fs.readJson(packageJsonFile) + expect(packageJson.registries).toEqual({ + "@acme": "https://existing.com/r/{name}.json", + }) + } finally { + await fs.rm(tempDir, { recursive: true }) + } + }) + + it("should throw when neither components.json nor package.json exists", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + + try { + await expect( + addRegistriesToConfig( + ["@acme=https://acme.com/r/{name}.json"], + tempDir, + { silent: true } + ) + ).rejects.toThrow(/No .*components\.json.* or .*package\.json.* found/) + } finally { + await fs.rm(tempDir, { recursive: true }) + } + }) +}) diff --git a/packages/shadcn/src/commands/registry/add.ts b/packages/shadcn/src/commands/registry/add.ts index 773c1f41c6c..0fa96088702 100644 --- a/packages/shadcn/src/commands/registry/add.ts +++ b/packages/shadcn/src/commands/registry/add.ts @@ -70,20 +70,27 @@ function pluralize(count: number, singular: string, plural: string) { return `${count} ${count === 1 ? singular : plural}` } -async function addRegistriesToConfig( +export async function addRegistriesToConfig( registryArgs: string[], cwd: string, options: { silent?: boolean } ) { - const configPath = path.resolve(cwd, "components.json") - if (!fs.existsSync(configPath)) { + // Write to components.json when it exists, otherwise fall back to + // package.json. This mirrors how registries are resolved. + const configPath = ["components.json", "package.json"] + .map((file) => path.resolve(cwd, file)) + .find((file) => fs.existsSync(file)) + + if (!configPath) { throw new Error( - `No ${highlighter.info("components.json")} found. Run ${highlighter.info( - "shadcn init" - )} first.` + `No ${highlighter.info("components.json")} or ${highlighter.info( + "package.json" + )} found. Run ${highlighter.info("shadcn init")} first.` ) } + const configFileName = path.basename(configPath) + const parsed = registryArgs.map(parseRegistryArg) const needsLookup = parsed.filter((p) => !p.url) let registriesIndex: { name: string; url: string }[] = [] @@ -179,7 +186,7 @@ async function addRegistriesToConfig( }, } - const writeSpinner = spinner("Updating components.json.", { + const writeSpinner = spinner(`Updating ${configFileName}.`, { silent: options.silent, }).start() await fs.writeJson(configPath, updatedConfig, { spaces: 2 }) diff --git a/packages/shadcn/src/commands/search.test.ts b/packages/shadcn/src/commands/search.test.ts index 5f1d6f6eb94..9633799d1e7 100644 --- a/packages/shadcn/src/commands/search.test.ts +++ b/packages/shadcn/src/commands/search.test.ts @@ -82,6 +82,8 @@ vi.mock("@/src/utils/registries", () => ({ ensureRegistriesInConfig: vi.fn(() => ({ config: baseConfig, newRegistries: [], + discoveredRegistries: {}, + packageJsonRegistries: {}, })), })) diff --git a/packages/shadcn/src/migrations/migrate-radix.ts b/packages/shadcn/src/migrations/migrate-radix.ts index 62051c135ee..f9a16f26f7a 100644 --- a/packages/shadcn/src/migrations/migrate-radix.ts +++ b/packages/shadcn/src/migrations/migrate-radix.ts @@ -116,6 +116,7 @@ export async function migrateRadix( cwd: basePath, onlyFiles: true, ignore: ["**/node_modules/**"], + suppressErrors: true, }) } else { const fullPath = path.resolve(basePath, options.path) @@ -131,6 +132,7 @@ export async function migrateRadix( cwd: basePath, onlyFiles: true, ignore: ["**/node_modules/**"], + suppressErrors: true, }) } else if (stat.isFile()) { files = [options.path] @@ -154,6 +156,7 @@ export async function migrateRadix( files = await fg("**/*.{js,ts,jsx,tsx}", { cwd: basePath, onlyFiles: true, + suppressErrors: true, }) } diff --git a/packages/shadcn/src/migrations/migrate-rtl.ts b/packages/shadcn/src/migrations/migrate-rtl.ts index ed55f9d1a0b..7ba6ed94c9a 100644 --- a/packages/shadcn/src/migrations/migrate-rtl.ts +++ b/packages/shadcn/src/migrations/migrate-rtl.ts @@ -36,6 +36,7 @@ export async function migrateRtl( cwd: basePath, onlyFiles: true, ignore: ["**/node_modules/**"], + suppressErrors: true, }) } else { const fullPath = path.resolve(basePath, options.path) @@ -51,6 +52,7 @@ export async function migrateRtl( cwd: basePath, onlyFiles: true, ignore: ["**/node_modules/**"], + suppressErrors: true, }) } else if (stat.isFile()) { files = [options.path] @@ -74,6 +76,7 @@ export async function migrateRtl( files = await fg("**/*.{js,ts,jsx,tsx}", { cwd: basePath, onlyFiles: true, + suppressErrors: true, }) } diff --git a/packages/shadcn/src/registry/api.test.ts b/packages/shadcn/src/registry/api.test.ts index 345d198182f..c73232e80d0 100644 --- a/packages/shadcn/src/registry/api.test.ts +++ b/packages/shadcn/src/registry/api.test.ts @@ -1539,7 +1539,7 @@ describe("getRegistriesConfig", () => { } }) - it("should prefer components.json over package.json", async () => { + it("should merge registries from components.json and package.json", async () => { const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) const componentsJsonFile = path.join(tempDir, "components.json") const packageJsonFile = path.join(tempDir, "package.json") @@ -1567,8 +1567,8 @@ describe("getRegistriesConfig", () => { expect(result.registries).toEqual({ ...BUILTIN_REGISTRIES, "@components": "https://components.com/{name}.json", + "@package": "https://package.com/{name}.json", }) - expect(result.registries["@package"]).toBeUndefined() } finally { await fs.unlink(componentsJsonFile) await fs.unlink(packageJsonFile) @@ -1576,7 +1576,46 @@ describe("getRegistriesConfig", () => { } }) - it("should not fall back to package.json when components.json has no registries", async () => { + it("should prefer components.json over package.json for conflicting registries", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const componentsJsonFile = path.join(tempDir, "components.json") + const packageJsonFile = path.join(tempDir, "package.json") + + await fs.writeFile( + componentsJsonFile, + JSON.stringify({ + registries: { + "@acme": "https://components.com/{name}.json", + }, + }) + ) + await fs.writeFile( + packageJsonFile, + JSON.stringify({ + registries: { + "@acme": "https://package.com/{name}.json", + "@package": "https://package.com/{name}.json", + }, + }) + ) + + try { + const result = await getRegistriesConfig(tempDir) + + expect(result.registries["@acme"]).toBe( + "https://components.com/{name}.json" + ) + expect(result.registries["@package"]).toBe( + "https://package.com/{name}.json" + ) + } finally { + await fs.unlink(componentsJsonFile) + await fs.unlink(packageJsonFile) + await fs.rmdir(tempDir) + } + }) + + it("should merge package.json registries when components.json has no registries", async () => { const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) const componentsJsonFile = path.join(tempDir, "components.json") const packageJsonFile = path.join(tempDir, "package.json") @@ -1599,7 +1638,51 @@ describe("getRegistriesConfig", () => { try { const result = await getRegistriesConfig(tempDir) - expect(result.registries).toEqual(BUILTIN_REGISTRIES) + expect(result.registries).toEqual({ + ...BUILTIN_REGISTRIES, + "@package": "https://package.com/{name}.json", + }) + } finally { + await fs.unlink(componentsJsonFile) + await fs.unlink(packageJsonFile) + await fs.rmdir(tempDir) + } + }) + + it("should throw ConfigParseError for invalid package.json registries even when components.json exists", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const componentsJsonFile = path.join(tempDir, "components.json") + const packageJsonFile = path.join(tempDir, "package.json") + + await fs.writeFile( + componentsJsonFile, + JSON.stringify({ + registries: { + "@components": "https://components.com/{name}.json", + }, + }) + ) + await fs.writeFile( + packageJsonFile, + JSON.stringify({ + registries: { + "@invalid": { + headers: { + Authorization: "Bearer token", + }, + }, + }, + }) + ) + + try { + await getRegistriesConfig(tempDir) + expect.fail("Should have thrown ConfigParseError") + } catch (error) { + expect(error).toBeInstanceOf(ConfigParseError) + if (error instanceof ConfigParseError) { + expect(error.configFile).toBe("package.json") + } } finally { await fs.unlink(componentsJsonFile) await fs.unlink(packageJsonFile) diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts index dd9ecc869df..ebfd5ef24c8 100644 --- a/packages/shadcn/src/registry/api.ts +++ b/packages/shadcn/src/registry/api.ts @@ -220,6 +220,10 @@ export async function getRegistriesConfig( packageRegistriesExplorer.clearCaches() } + const packageJsonRegistries = await getPackageJsonRegistries(cwd) + + // Registries are merged from package.json and components.json, with + // components.json taking precedence per key. const componentsJsonPath = path.resolve(cwd, "components.json") if (existsSync(componentsJsonPath)) { const configResult = await explorer.load(componentsJsonPath) @@ -232,26 +236,33 @@ export async function getRegistriesConfig( return { registries: { ...BUILTIN_REGISTRIES, + ...packageJsonRegistries, ...config.registries, }, } } - const packageJsonPath = path.resolve(cwd, "package.json") - if (existsSync(packageJsonPath)) { - const configResult = await packageRegistriesExplorer.load(packageJsonPath) - return parseRegistriesConfig( - cwd, - { - registries: configResult?.config, - }, - "package.json" - ) + return { + registries: packageJsonRegistries, } +} - return { - registries: {}, +export async function getPackageJsonRegistries( + cwd: string +): Promise> { + const packageJsonPath = path.resolve(cwd, "package.json") + if (!existsSync(packageJsonPath)) { + return {} } + + const configResult = await packageRegistriesExplorer.load(packageJsonPath) + return parseRegistriesConfig( + cwd, + { + registries: configResult?.config, + }, + "package.json" + ).registries } function parseRegistriesConfig( diff --git a/packages/shadcn/src/utils/get-config.ts b/packages/shadcn/src/utils/get-config.ts index 15304e06296..8a8028ca073 100644 --- a/packages/shadcn/src/utils/get-config.ts +++ b/packages/shadcn/src/utils/get-config.ts @@ -280,6 +280,7 @@ export async function findPackageRoot(cwd: string, resolvedPath: string) { cwd: commonRoot, deep: 3, ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/public/**"], + suppressErrors: true, }) const matchingPackageRoot = packageRoots diff --git a/packages/shadcn/src/utils/get-monorepo-info.test.ts b/packages/shadcn/src/utils/get-monorepo-info.test.ts index 13eb79fc165..98d2598d303 100644 --- a/packages/shadcn/src/utils/get-monorepo-info.test.ts +++ b/packages/shadcn/src/utils/get-monorepo-info.test.ts @@ -129,6 +129,30 @@ describe("getMonorepoTargets", () => { expect(targets).toEqual([{ name: "apps/web", hasConfig: false }]) }) + it("should skip unreadable workspace directories", async () => { + await fs.writeFile( + path.join(tmpDir, "pnpm-workspace.yaml"), + "packages:\n - apps/**\n" + ) + await fs.writeJson(path.join(tmpDir, "package.json"), { name: "root" }) + + const webDir = path.join(tmpDir, "apps", "web") + await fs.ensureDir(webDir) + await fs.writeJson(path.join(webDir, "package.json"), { name: "web" }) + await fs.writeFile(path.join(webDir, "vite.config.ts"), "export default {}") + + const unreadableDir = path.join(tmpDir, "apps", "unreadable") + await fs.ensureDir(unreadableDir) + await fs.chmod(unreadableDir, 0o000) + + try { + const targets = await getMonorepoTargets(tmpDir) + expect(targets).toEqual([{ name: "apps/web", hasConfig: false }]) + } finally { + await fs.chmod(unreadableDir, 0o755) + } + }) + it("should set hasConfig when components.json exists", async () => { await fs.writeFile( path.join(tmpDir, "pnpm-workspace.yaml"), diff --git a/packages/shadcn/src/utils/get-monorepo-info.ts b/packages/shadcn/src/utils/get-monorepo-info.ts index c48dc1730cc..7d166fe8e58 100644 --- a/packages/shadcn/src/utils/get-monorepo-info.ts +++ b/packages/shadcn/src/utils/get-monorepo-info.ts @@ -61,6 +61,7 @@ export async function getMonorepoTargets(cwd: string) { cwd, onlyDirectories: true, ignore: ["**/node_modules/**"], + suppressErrors: true, }) const targets: { name: string; hasConfig: boolean }[] = [] diff --git a/packages/shadcn/src/utils/get-project-info.ts b/packages/shadcn/src/utils/get-project-info.ts index 209e1c24bc7..a55c4ffe638 100644 --- a/packages/shadcn/src/utils/get-project-info.ts +++ b/packages/shadcn/src/utils/get-project-info.ts @@ -63,6 +63,7 @@ export async function getProjectInfo( cwd, deep: 3, ignore: PROJECT_SHARED_IGNORE, + suppressErrors: true, } ), fs.pathExists(path.resolve(cwd, "src")), @@ -264,6 +265,7 @@ export async function getTailwindCssFile(cwd: string, configCssFile?: string) { cwd, deep: 5, ignore: PROJECT_SHARED_IGNORE, + suppressErrors: true, }), getTailwindVersion(cwd), ]) diff --git a/packages/shadcn/src/utils/registries.test.ts b/packages/shadcn/src/utils/registries.test.ts index 9c3aca5a1a0..99621e929c2 100644 --- a/packages/shadcn/src/utils/registries.test.ts +++ b/packages/shadcn/src/utils/registries.test.ts @@ -1,18 +1,22 @@ +import { + getPackageJsonRegistries, + getRegistriesIndex, +} from "@/src/registry/api" +import { resolveRegistryNamespaces } from "@/src/registry/namespaces" import type { Config } from "@/src/utils/get-config" import fs from "fs-extra" -import { afterEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { ensureRegistriesInConfig } from "./registries" // Mock dependencies. vi.mock("@/src/registry/namespaces", () => ({ - resolveRegistryNamespaces: vi.fn().mockResolvedValue(["@foo"]), + resolveRegistryNamespaces: vi.fn(), })) vi.mock("@/src/registry/api", () => ({ - getRegistriesIndex: vi.fn().mockResolvedValue({ - "@foo": "https://foo.com/r/{name}.json", - }), + getRegistriesIndex: vi.fn(), + getPackageJsonRegistries: vi.fn(), })) vi.mock("@/src/utils/spinner", () => ({ @@ -27,10 +31,23 @@ vi.mock("@/src/utils/spinner", () => ({ vi.mock("fs-extra", () => ({ default: { - writeFile: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn(), + readJson: vi.fn(), + existsSync: vi.fn(), }, })) +beforeEach(() => { + vi.mocked(resolveRegistryNamespaces).mockResolvedValue(["@foo"]) + vi.mocked(getRegistriesIndex).mockResolvedValue({ + "@foo": "https://foo.com/r/{name}.json", + }) + vi.mocked(getPackageJsonRegistries).mockResolvedValue({}) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + vi.mocked(fs.readJson).mockResolvedValue({}) + vi.mocked(fs.existsSync).mockReturnValue(true) +}) + afterEach(() => { vi.clearAllMocks() }) @@ -67,6 +84,11 @@ const baseConfig: Config = { }, } +function writtenConfig() { + const write = vi.mocked(fs.writeFile).mock.calls[0] + return JSON.parse(write[1] as string) +} + describe("ensureRegistriesInConfig", () => { it("does not write to disk when writeFile is false", async () => { const { config, newRegistries } = await ensureRegistriesInConfig( @@ -117,4 +139,130 @@ describe("ensureRegistriesInConfig", () => { // No new registries, so no write. expect(fs.writeFile).not.toHaveBeenCalled() }) + + it("resolves registries from package.json without fetching the index", async () => { + vi.mocked(getPackageJsonRegistries).mockResolvedValue({ + "@foo": "https://package.com/r/{name}.json", + }) + + const { config, newRegistries, discoveredRegistries } = + await ensureRegistriesInConfig(["@foo/bar"], baseConfig) + + expect(newRegistries).toEqual(["@foo"]) + expect(config.registries?.["@foo"]).toBe( + "https://package.com/r/{name}.json" + ) + expect(discoveredRegistries).toEqual({}) + + // Fully resolved from package.json: no index fetch, no write. + expect(getRegistriesIndex).not.toHaveBeenCalled() + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("resolves from package.json and falls back to the index", async () => { + vi.mocked(resolveRegistryNamespaces).mockResolvedValue(["@foo", "@pkg"]) + vi.mocked(getPackageJsonRegistries).mockResolvedValue({ + "@pkg": "https://package.com/r/{name}.json", + }) + + const { config, newRegistries } = await ensureRegistriesInConfig( + ["@foo/bar", "@pkg/baz"], + baseConfig + ) + + expect(newRegistries.sort()).toEqual(["@foo", "@pkg"]) + expect(config.registries?.["@foo"]).toBe("https://foo.com/r/{name}.json") + expect(config.registries?.["@pkg"]).toBe( + "https://package.com/r/{name}.json" + ) + + // Only the index-discovered registry is written to components.json. + expect(fs.writeFile).toHaveBeenCalledTimes(1) + expect(writtenConfig().registries).toEqual({ + "@foo": "https://foo.com/r/{name}.json", + }) + }) + + it("does not persist in-memory registries from a previous run", async () => { + // Simulate a config that already picked up a package.json registry + // in memory from an earlier ensureRegistriesInConfig call. + const configWithInMemoryRegistry: Config = { + ...baseConfig, + registries: { + "@pkg": "https://package.com/r/{name}.json", + }, + } + vi.mocked(resolveRegistryNamespaces).mockResolvedValue(["@bar"]) + vi.mocked(getRegistriesIndex).mockResolvedValue({ + "@bar": "https://bar.com/r/{name}.json", + }) + + const { config } = await ensureRegistriesInConfig( + ["@bar/baz"], + configWithInMemoryRegistry + ) + + expect(config.registries?.["@pkg"]).toBe( + "https://package.com/r/{name}.json" + ) + expect(config.registries?.["@bar"]).toBe("https://bar.com/r/{name}.json") + + // The write starts from the file on disk, so the in-memory registry + // never leaks into components.json. + expect(writtenConfig().registries).toEqual({ + "@bar": "https://bar.com/r/{name}.json", + }) + }) + + it("returns package.json registries when the index is unavailable", async () => { + vi.mocked(getPackageJsonRegistries).mockResolvedValue({ + "@foo": "https://package.com/r/{name}.json", + }) + vi.mocked(resolveRegistryNamespaces).mockResolvedValue(["@foo", "@bar"]) + vi.mocked(getRegistriesIndex).mockResolvedValue(null) + + const { config, newRegistries } = await ensureRegistriesInConfig( + ["@foo/bar", "@bar/baz"], + baseConfig + ) + + expect(newRegistries).toEqual(["@foo"]) + expect(config.registries?.["@foo"]).toBe( + "https://package.com/r/{name}.json" + ) + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("skips the write when components.json does not exist", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + + const { config, newRegistries } = await ensureRegistriesInConfig( + ["@foo/bar"], + baseConfig + ) + + // Config is still updated in memory. + expect(newRegistries).toEqual(["@foo"]) + expect(config.registries?.["@foo"]).toBe("https://foo.com/r/{name}.json") + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("preserves unrelated fields in components.json when writing", async () => { + vi.mocked(fs.readJson).mockResolvedValue({ + style: "new-york", + registries: { + "@keep": "https://keep.com/r/{name}.json", + }, + }) + + await ensureRegistriesInConfig(["@foo/bar"], baseConfig) + + expect(writtenConfig()).toEqual({ + style: "new-york", + registries: { + "@keep": "https://keep.com/r/{name}.json", + "@foo": "https://foo.com/r/{name}.json", + }, + }) + }) }) diff --git a/packages/shadcn/src/utils/registries.ts b/packages/shadcn/src/utils/registries.ts index f0471eaa9e6..60afdd7abb2 100644 --- a/packages/shadcn/src/utils/registries.ts +++ b/packages/shadcn/src/utils/registries.ts @@ -1,8 +1,10 @@ import path from "path" -import { getRegistriesIndex } from "@/src/registry/api" +import { + getPackageJsonRegistries, + getRegistriesIndex, +} from "@/src/registry/api" import { BUILTIN_REGISTRIES } from "@/src/registry/constants" import { resolveRegistryNamespaces } from "@/src/registry/namespaces" -import { rawConfigSchema } from "@/src/registry/schema" import { Config } from "@/src/utils/get-config" import { spinner } from "@/src/utils/spinner" import fs from "fs-extra" @@ -34,33 +36,55 @@ export async function ensureRegistriesInConfig( return { config, newRegistries: [], + discoveredRegistries: {}, + packageJsonRegistries: {}, } } + // Resolve missing registries from package.json first. These are merged + // into the config in memory and never written to components.json. + const declaredRegistries = await getPackageJsonRegistries( + config.resolvedPaths.cwd + ) + const packageJsonRegistries: NonNullable = {} + const unresolvedRegistries: string[] = [] + for (const registry of missingRegistries) { + if (declaredRegistries[registry]) { + packageJsonRegistries[registry] = declaredRegistries[registry] + } else { + unresolvedRegistries.push(registry) + } + } + + // Fall back to the registries index for anything not in package.json. // We'll fail silently if we can't fetch the registry index. // The error handling by caller will guide user to add the missing registries. - const registryIndex = await getRegistriesIndex({ - useCache: process.env.NODE_ENV !== "development", - }) + const discoveredRegistries: Record = {} + if (unresolvedRegistries.length > 0) { + const registryIndex = await getRegistriesIndex({ + useCache: process.env.NODE_ENV !== "development", + }) - if (!registryIndex) { - return { - config, - newRegistries: [], + if (registryIndex) { + for (const registry of unresolvedRegistries) { + if (registryIndex[registry]) { + discoveredRegistries[registry] = registryIndex[registry] + } + } } } - const foundRegistries: Record = {} - for (const registry of missingRegistries) { - if (registryIndex[registry]) { - foundRegistries[registry] = registryIndex[registry] - } + const foundRegistries = { + ...packageJsonRegistries, + ...discoveredRegistries, } if (Object.keys(foundRegistries).length === 0) { return { config, newRegistries: [], + discoveredRegistries: {}, + packageJsonRegistries: {}, } } @@ -79,23 +103,36 @@ export async function ensureRegistriesInConfig( }, } - if (options.writeFile) { - const { resolvedPaths, ...configWithoutResolvedPaths } = - newConfigWithRegistries - const configSpinner = spinner("Updating components.json.", { - silent: options.silent, - }).start() - const updatedConfig = rawConfigSchema.parse(configWithoutResolvedPaths) - await fs.writeFile( - path.resolve(config.resolvedPaths.cwd, "components.json"), - JSON.stringify(updatedConfig, null, 2) + "\n", - "utf-8" - ) - configSpinner.succeed() + // Only registries discovered from the registries index are persisted. + // The written config is built from the file on disk so registries that + // only live in memory (e.g. from package.json) are never persisted. + if (options.writeFile && Object.keys(discoveredRegistries).length > 0) { + const configPath = path.resolve(config.resolvedPaths.cwd, "components.json") + if (fs.existsSync(configPath)) { + const configSpinner = spinner("Updating components.json.", { + silent: options.silent, + }).start() + const rawConfig = await fs.readJson(configPath) + const updatedConfig = { + ...rawConfig, + registries: { + ...rawConfig.registries, + ...discoveredRegistries, + }, + } + await fs.writeFile( + configPath, + JSON.stringify(updatedConfig, null, 2) + "\n", + "utf-8" + ) + configSpinner.succeed() + } } return { config: newConfigWithRegistries, newRegistries: Object.keys(foundRegistries), + discoveredRegistries, + packageJsonRegistries, } } diff --git a/packages/shadcn/src/utils/workspace.ts b/packages/shadcn/src/utils/workspace.ts index 04d357d8da7..1d77d3dc510 100644 --- a/packages/shadcn/src/utils/workspace.ts +++ b/packages/shadcn/src/utils/workspace.ts @@ -140,6 +140,7 @@ async function loadWorkspacePackages(root: string) { { cwd: root, ignore: ["**/node_modules/**"], + suppressErrors: true, } ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2822eaf146d..981239e54f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,7 +328,7 @@ importers: specifier: ^0.0.1 version: 0.0.1 shadcn: - specifier: 4.17.0 + specifier: 4.18.0 version: link:../../packages/shadcn shiki: specifier: ^3.23.0