Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions lib/modules/manager/bun/bunfig.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
});
107 changes: 107 additions & 0 deletions lib/modules/manager/bun/bunfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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.
* 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(),
token: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
}),
])
.transform((val) => (typeof val === 'string' ? 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<typeof BunfigSchema>;

/**
* 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<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