diff --git a/lib/modules/manager/bun/bunfig.spec.ts b/lib/modules/manager/bun/bunfig.spec.ts new file mode 100644 index 00000000000..92335bb9d19 --- /dev/null +++ b/lib/modules/manager/bun/bunfig.spec.ts @@ -0,0 +1,194 @@ +import { parseBunfigToml, resolveRegistryUrl } from './bunfig'; + +describe('modules/manager/bun/bunfig', () => { + describe('parseBunfigToml', () => { + it('returns null for invalid TOML', () => { + expect(parseBunfigToml('invalid toml {')).toBeNull(); + }); + + it('returns empty config for empty TOML', () => { + expect(parseBunfigToml('')).toEqual({}); + }); + + it('returns null for valid TOML with invalid schema', () => { + // Valid TOML but registry is not a string or valid object + const toml = ` +[install] +registry = 123 +`; + expect(parseBunfigToml(toml)).toBeNull(); + }); + + it('parses simple string registry', () => { + const toml = ` +[install] +registry = "https://registry.example.com" +`; + expect(parseBunfigToml(toml)).toEqual({ + install: { + registry: 'https://registry.example.com', + }, + }); + }); + + it('parses registry object with url and extracts url', () => { + const toml = ` +[install] +registry = { url = "https://registry.example.com", token = "abc123" } +`; + expect(parseBunfigToml(toml)).toEqual({ + install: { + registry: 'https://registry.example.com', + }, + }); + }); + + it('parses scoped registries', () => { + const toml = ` +[install.scopes] +myorg = "https://registry.myorg.com" +`; + expect(parseBunfigToml(toml)).toEqual({ + install: { + scopes: { + myorg: 'https://registry.myorg.com', + }, + }, + }); + }); + + it('parses mixed default and scoped registries', () => { + const toml = ` +[install] +registry = "https://registry.example.com" + +[install.scopes] +myorg = "https://registry.myorg.com" +otherorg = { url = "https://registry.other.com", token = "secret" } +`; + expect(parseBunfigToml(toml)).toEqual({ + install: { + registry: 'https://registry.example.com', + scopes: { + myorg: 'https://registry.myorg.com', + otherorg: 'https://registry.other.com', + }, + }, + }); + }); + + it('ignores unrelated TOML sections', () => { + const toml = ` +[run] +shell = "zsh" + +[install] +registry = "https://registry.example.com" +`; + expect(parseBunfigToml(toml)).toEqual({ + install: { + registry: 'https://registry.example.com', + }, + }); + }); + }); + + describe('resolveRegistryUrl', () => { + it('returns null when no install config', () => { + expect(resolveRegistryUrl('dep', {})).toBeNull(); + }); + + it('returns null when no registry configured', () => { + expect(resolveRegistryUrl('dep', { install: {} })).toBeNull(); + }); + + it('returns default registry for unscoped package', () => { + const config = { + install: { + registry: 'https://registry.example.com', + }, + }; + expect(resolveRegistryUrl('lodash', config)).toBe( + 'https://registry.example.com', + ); + }); + + it('returns default registry for unscoped package with object config', () => { + // After schema transform, registry is always a string URL + const config = { + install: { + registry: 'https://registry.example.com', + }, + }; + expect(resolveRegistryUrl('lodash', config)).toBe( + 'https://registry.example.com', + ); + }); + + it('returns scoped registry for scoped package', () => { + const config = { + install: { + registry: 'https://registry.example.com', + scopes: { + myorg: 'https://registry.myorg.com', + }, + }, + }; + expect(resolveRegistryUrl('@myorg/utils', config)).toBe( + 'https://registry.myorg.com', + ); + }); + + it('returns scoped registry for scoped package with object config', () => { + // After schema transform, scoped registries are always string URLs + const config = { + install: { + scopes: { + myorg: 'https://registry.myorg.com', + }, + }, + }; + expect(resolveRegistryUrl('@myorg/utils', config)).toBe( + 'https://registry.myorg.com', + ); + }); + + it('falls back to default registry for unmatched scope', () => { + const config = { + install: { + registry: 'https://registry.example.com', + scopes: { + myorg: 'https://registry.myorg.com', + }, + }, + }; + expect(resolveRegistryUrl('@other/pkg', config)).toBe( + 'https://registry.example.com', + ); + }); + + it('returns null for unmatched scope with no default', () => { + const config = { + install: { + scopes: { + myorg: 'https://registry.myorg.com', + }, + }, + }; + expect(resolveRegistryUrl('@other/pkg', config)).toBeNull(); + }); + + it('handles scope with @ prefix in config', () => { + const config = { + install: { + scopes: { + '@myorg': 'https://registry.myorg.com', + }, + }, + }; + expect(resolveRegistryUrl('@myorg/utils', config)).toBe( + 'https://registry.myorg.com', + ); + }); + }); +}); diff --git a/lib/modules/manager/bun/bunfig.ts b/lib/modules/manager/bun/bunfig.ts new file mode 100644 index 00000000000..d127a83cd61 --- /dev/null +++ b/lib/modules/manager/bun/bunfig.ts @@ -0,0 +1,100 @@ +import { z } from 'zod'; +import { logger } from '../../../logger'; +import { findLocalSiblingOrParent, readLocalFile } from '../../../util/fs'; +import { Result } from '../../../util/result'; +import { parse as parseToml } from '../../../util/toml'; + +/** + * Schema for bunfig.toml registry configuration. + * Supports both string URLs and object with url. + * Transforms to extract just the URL string. + * See: https://bun.sh/docs/runtime/bunfig#install-registry + */ +const BunfigRegistrySchema = z.union([ + z.string(), + z.object({ url: z.string() }).transform((val) => val.url), +]); + +const BunfigInstallSchema = z.object({ + registry: BunfigRegistrySchema.optional(), + scopes: z.record(z.string(), BunfigRegistrySchema).optional(), +}); + +const BunfigSchema = z.object({ + install: BunfigInstallSchema.optional(), +}); + +export type BunfigConfig = z.infer; + +/** + * Resolves the registry URL for a given package name based on bunfig.toml config. + * Scoped packages (@org/pkg) are matched against scoped registries first. + */ +export function resolveRegistryUrl( + packageName: string, + bunfigConfig: BunfigConfig, +): string | null { + const install = bunfigConfig.install; + if (!install) { + return null; + } + + // Check scoped registries first + if (install.scopes) { + for (const [scope, registryUrl] of Object.entries(install.scopes)) { + // Bun scopes in bunfig.toml don't include the @ prefix + const scopePrefix = scope.startsWith('@') ? scope : `@${scope}`; + if (packageName.startsWith(`${scopePrefix}/`)) { + return registryUrl; + } + } + } + + // Fall back to default registry + if (install.registry) { + return install.registry; + } + + return null; +} + +/** + * Loads and parses bunfig.toml from the filesystem. + * Returns null if file not found or parsing fails. + */ +export async function loadBunfigToml( + packageFile: string, +): Promise { + const bunfigFileName = await findLocalSiblingOrParent( + packageFile, + 'bunfig.toml', + ); + + if (!bunfigFileName) { + return null; + } + + const content = await readLocalFile(bunfigFileName, 'utf8'); + if (!content) { + return null; + } + + return parseBunfigToml(content); +} + +/** + * Parses bunfig.toml content string into a typed config object. + */ +export function parseBunfigToml(content: string): BunfigConfig | null { + try { + const parsed = parseToml(content); + return Result.parse(parsed, BunfigSchema) + .onError((err) => { + logger.debug({ err }, 'Failed to parse bunfig.toml'); + }) + .unwrapOrNull(); + } catch (err) { + logger.debug({ err }, 'Failed to parse bunfig.toml TOML syntax'); + return null; + } +} diff --git a/lib/modules/manager/bun/extract.spec.ts b/lib/modules/manager/bun/extract.spec.ts index 6c2bd75002f..80251c874e9 100644 --- a/lib/modules/manager/bun/extract.spec.ts +++ b/lib/modules/manager/bun/extract.spec.ts @@ -215,4 +215,179 @@ describe('modules/manager/bun/extract', () => { ]); }); }); + + describe('bunfig.toml registry support', () => { + it('applies default registry from bunfig.toml', async () => { + vi.mocked(fs.getSiblingFileName).mockReturnValue('package.json'); + vi.mocked(fs.readLocalFile).mockResolvedValueOnce( + JSON.stringify({ + name: 'test', + version: '0.0.1', + dependencies: { lodash: '1.0.0' }, + }), + ); + vi.mocked(fs.findLocalSiblingOrParent).mockResolvedValueOnce( + 'bunfig.toml', + ); + vi.mocked(fs.readLocalFile).mockResolvedValueOnce(` +[install] +registry = "https://registry.example.com" +`); + + const packageFiles = await extractAllPackageFiles({}, ['bun.lock']); + + expect(packageFiles[0].deps[0].registryUrls).toEqual([ + 'https://registry.example.com', + ]); + }); + + it('applies scoped registry from bunfig.toml', async () => { + vi.mocked(fs.getSiblingFileName).mockReturnValue('package.json'); + vi.mocked(fs.readLocalFile).mockResolvedValueOnce( + JSON.stringify({ + name: 'test', + version: '0.0.1', + dependencies: { + lodash: '1.0.0', + '@myorg/utils': '2.0.0', + }, + }), + ); + vi.mocked(fs.findLocalSiblingOrParent).mockResolvedValueOnce( + 'bunfig.toml', + ); + vi.mocked(fs.readLocalFile).mockResolvedValueOnce(` +[install] +registry = "https://registry.example.com" + +[install.scopes] +myorg = "https://registry.myorg.com" +`); + + const packageFiles = await extractAllPackageFiles({}, ['bun.lock']); + + const lodashDep = packageFiles[0].deps.find( + (d) => d.depName === 'lodash', + ); + const myorgDep = packageFiles[0].deps.find( + (d) => d.depName === '@myorg/utils', + ); + + expect(lodashDep?.registryUrls).toEqual(['https://registry.example.com']); + expect(myorgDep?.registryUrls).toEqual(['https://registry.myorg.com']); + }); + + it('handles missing bunfig.toml gracefully', async () => { + vi.mocked(fs.getSiblingFileName).mockReturnValue('package.json'); + vi.mocked(fs.readLocalFile).mockResolvedValueOnce( + JSON.stringify({ + name: 'test', + version: '0.0.1', + dependencies: { lodash: '1.0.0' }, + }), + ); + vi.mocked(fs.findLocalSiblingOrParent).mockResolvedValueOnce(null); + + const packageFiles = await extractAllPackageFiles({}, ['bun.lock']); + + expect(packageFiles[0].deps[0].registryUrls).toBeUndefined(); + }); + + it('handles empty bunfig.toml file gracefully', async () => { + vi.mocked(fs.getSiblingFileName).mockReturnValue('package.json'); + vi.mocked(fs.readLocalFile).mockResolvedValueOnce( + JSON.stringify({ + name: 'test', + version: '0.0.1', + dependencies: { lodash: '1.0.0' }, + }), + ); + vi.mocked(fs.findLocalSiblingOrParent).mockResolvedValueOnce( + 'bunfig.toml', + ); + // bunfig.toml exists but is empty/null + vi.mocked(fs.readLocalFile).mockResolvedValueOnce(null); + + const packageFiles = await extractAllPackageFiles({}, ['bun.lock']); + + expect(packageFiles[0].deps[0].registryUrls).toBeUndefined(); + }); + + it('applies bunfig.toml registry to workspace packages', async () => { + vi.mocked(fs.getSiblingFileName).mockReturnValue('package.json'); + vi.mocked(fs.readLocalFile) + // Root package.json with workspaces + .mockResolvedValueOnce( + JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: ['packages/*'], + dependencies: { lodash: '1.0.0' }, + }), + ); + vi.mocked(fs.findLocalSiblingOrParent).mockResolvedValueOnce( + 'bunfig.toml', + ); + vi.mocked(fs.readLocalFile).mockResolvedValueOnce(` +[install] +registry = "https://registry.example.com" +`); + vi.mocked(fs.getParentDir).mockReturnValueOnce(''); + // Workspace package.json + vi.mocked(fs.readLocalFile).mockResolvedValueOnce( + JSON.stringify({ + name: 'pkg1', + version: '1.0.0', + dependencies: { axios: '2.0.0' }, + }), + ); + + const matchedFiles = [ + 'bun.lock', + 'package.json', + 'packages/pkg1/package.json', + ]; + + const packageFiles = await extractAllPackageFiles({}, matchedFiles); + + // Root package should have registry applied + const rootPkg = packageFiles.find( + (p) => p.packageFile === 'package.json', + ); + expect(rootPkg?.deps[0].registryUrls).toEqual([ + 'https://registry.example.com', + ]); + + // Workspace package should also have registry applied + const workspacePkg = packageFiles.find( + (p) => p.packageFile === 'packages/pkg1/package.json', + ); + expect(workspacePkg?.deps[0].registryUrls).toEqual([ + 'https://registry.example.com', + ]); + }); + + it('handles invalid bunfig.toml schema gracefully', async () => { + vi.mocked(fs.getSiblingFileName).mockReturnValue('package.json'); + vi.mocked(fs.readLocalFile).mockResolvedValueOnce( + JSON.stringify({ + name: 'test', + version: '0.0.1', + dependencies: { lodash: '1.0.0' }, + }), + ); + vi.mocked(fs.findLocalSiblingOrParent).mockResolvedValueOnce( + 'bunfig.toml', + ); + // Valid TOML but invalid schema (registry should be string or object with url) + vi.mocked(fs.readLocalFile).mockResolvedValueOnce(` +[install] +registry = 123 +`); + + const packageFiles = await extractAllPackageFiles({}, ['bun.lock']); + + expect(packageFiles[0].deps[0].registryUrls).toBeUndefined(); + }); + }); }); diff --git a/lib/modules/manager/bun/extract.ts b/lib/modules/manager/bun/extract.ts index 928240c6126..a9ef59b8b37 100644 --- a/lib/modules/manager/bun/extract.ts +++ b/lib/modules/manager/bun/extract.ts @@ -5,11 +5,17 @@ import { getSiblingFileName, readLocalFile, } from '../../../util/fs'; +import { NpmDatasource } from '../../datasource/npm'; import { extractPackageJson } from '../npm/extract/common/package-file'; import type { NpmPackage } from '../npm/extract/types'; import type { NpmManagerData } from '../npm/types'; import type { ExtractConfig, PackageFile } from '../types'; +import { + type BunfigConfig, + loadBunfigToml, + resolveRegistryUrl, +} from './bunfig'; import { filesMatchingWorkspaces } from './utils'; function matchesFileName(fileNameWithPath: string, fileName: string): boolean { @@ -18,6 +24,26 @@ function matchesFileName(fileNameWithPath: string, fileName: string): boolean { ); } +/** + * Applies registry URLs from bunfig.toml to dependencies. + */ +function applyRegistryUrls( + packageFile: PackageFile, + bunfigConfig: BunfigConfig, +): void { + for (const dep of packageFile.deps) { + if (dep.depName && dep.datasource === NpmDatasource.id) { + const registryUrl = resolveRegistryUrl( + dep.packageName ?? dep.depName, + bunfigConfig, + ); + if (registryUrl) { + dep.registryUrls = [registryUrl]; + } + } + } +} + export async function processPackageFile( packageFile: string, ): Promise { @@ -65,6 +91,15 @@ export async function extractAllPackageFiles( if (res) { packageFiles.push({ ...res, lockFiles: [lockFile] }); } + + // Load bunfig.toml for registry configuration + const bunfigConfig = await loadBunfigToml(packageFile); + + // Apply registry URLs from bunfig.toml if present + if (bunfigConfig && res) { + applyRegistryUrls(res, bunfigConfig); + } + // Check if package.json contains workspaces const workspaces = res?.managerData?.workspaces; @@ -84,6 +119,10 @@ export async function extractAllPackageFiles( for (const workspaceFile of workspacePackageFiles) { const res = await processPackageFile(workspaceFile); if (res) { + // Apply registry URLs from root bunfig.toml to workspace packages + if (bunfigConfig) { + applyRegistryUrls(res, bunfigConfig); + } packageFiles.push({ ...res, lockFiles: [lockFile] }); } }