Skip to content
204 changes: 204 additions & 0 deletions lib/modules/manager/bun/bunfig.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
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', () => {
const toml = `
[install]
registry = { url = "https://registry.example.com", token = "abc123" }
`;
expect(parseBunfigToml(toml)).toEqual({
install: {
registry: {
url: 'https://registry.example.com',
token: 'abc123',
},
},
});
});

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: {
url: 'https://registry.other.com',
token: 'secret',
},
},
},
});
});

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 object url for unscoped package', () => {
const config = {
install: {
registry: {
url: 'https://registry.example.com',
token: 'abc',
},
},
};
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 object url for scoped package', () => {
const config = {
install: {
scopes: {
myorg: {
url: 'https://registry.myorg.com',
token: 'secret',
},
},
},
};
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',
);
});
});
});
112 changes: 112 additions & 0 deletions lib/modules/manager/bun/bunfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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/token/username/password.
* See: https://bun.sh/docs/runtime/bunfig#install-registry
*/
const BunfigRegistrySchema = z.union([
z.string(),
z.object({
url: z.string(),
token: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
}),
]);

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<typeof BunfigSchema>;
export type BunfigRegistryConfig = z.infer<typeof BunfigRegistrySchema>;

/**
* Extracts the URL from a registry config (string or object).
*/
function getRegistryUrl(config: BunfigRegistryConfig): string {
return typeof config === 'string' ? config : config.url;
}

/**
* 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 in install.scopes) {
// Bun scopes in bunfig.toml don't include the @ prefix
const scopePrefix = scope.startsWith('@') ? scope : `@${scope}`;
if (packageName.startsWith(`${scopePrefix}/`)) {
return getRegistryUrl(install.scopes[scope]);
}
}
}

// Fall back to default registry
if (install.registry) {
return getRegistryUrl(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<BunfigConfig | null> {
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;
}
}
Loading