diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts index 29f35185311300193ae6187d089878ce1f6cd1c4..c5cbbea651b770f37df939b6f8bad545d2a9ebf7 100644 --- a/lib/modules/manager/api.ts +++ b/lib/modules/manager/api.ts @@ -11,6 +11,7 @@ import * as bazelisk from './bazelisk'; import * as bicep from './bicep'; import * as bitbucketPipelines from './bitbucket-pipelines'; import * as buildkite from './buildkite'; +import * as bun from './bun'; import * as bundler from './bundler'; import * as cake from './cake'; import * as cargo from './cargo'; @@ -101,6 +102,7 @@ api.set('bazelisk', bazelisk); api.set('bicep', bicep); api.set('bitbucket-pipelines', bitbucketPipelines); api.set('buildkite', buildkite); +api.set('bun', bun); api.set('bundler', bundler); api.set('cake', cake); api.set('cargo', cargo); diff --git a/lib/modules/manager/bun/artifacts.spec.ts b/lib/modules/manager/bun/artifacts.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..92f23ac660b7b764568659dcd9a77ebe9c2eb326 --- /dev/null +++ b/lib/modules/manager/bun/artifacts.spec.ts @@ -0,0 +1,129 @@ +import _fs from 'fs-extra'; +import { mocked } from '../../../../test/util'; +import { GlobalConfig } from '../../../config/global'; +import type { RepoGlobalConfig } from '../../../config/types'; +import { TEMPORARY_ERROR } from '../../../constants/error-messages'; +import { exec as _exec } from '../../../util/exec'; +import { ExecError } from '../../../util/exec/exec-error'; +import type { UpdateArtifact } from '../types'; +import { updateArtifacts } from './artifacts'; + +jest.mock('../../../util/exec'); +jest.mock('fs-extra'); + +const exec = mocked(_exec); +const fs = mocked(_fs); + +const globalConfig: RepoGlobalConfig = { + localDir: '', +}; + +describe('modules/manager/bun/artifacts', () => { + describe('updateArtifacts()', () => { + let updateArtifact: UpdateArtifact; + + beforeEach(() => { + GlobalConfig.set(globalConfig); + updateArtifact = { + config: {}, + newPackageFileContent: '', + packageFileName: '', + updatedDeps: [], + }; + }); + + it('skips if no updatedDeps and no lockFileMaintenance', async () => { + expect(await updateArtifacts(updateArtifact)).toBeNull(); + }); + + it('skips if no lock file in config', async () => { + updateArtifact.updatedDeps = [{}]; + expect(await updateArtifacts(updateArtifact)).toBeNull(); + }); + + it('skips if cannot read lock file', async () => { + updateArtifact.updatedDeps = [{}]; + updateArtifact.config.lockFiles = ['bun.lockb']; + expect(await updateArtifacts(updateArtifact)).toBeNull(); + }); + + it('returns null if lock content unchanged', async () => { + updateArtifact.updatedDeps = [{}]; + updateArtifact.config.lockFiles = ['bun.lockb']; + const oldLock = Buffer.from('old'); + fs.readFile.mockResolvedValueOnce(oldLock as never); + fs.readFile.mockResolvedValueOnce(oldLock as never); + expect(await updateArtifacts(updateArtifact)).toBeNull(); + }); + + it('returns updated lock content', async () => { + updateArtifact.updatedDeps = [{}]; + updateArtifact.config.lockFiles = ['bun.lockb']; + const oldLock = Buffer.from('old'); + fs.readFile.mockResolvedValueOnce(oldLock as never); + const newLock = Buffer.from('new'); + fs.readFile.mockResolvedValueOnce(newLock as never); + expect(await updateArtifacts(updateArtifact)).toEqual([ + { + file: { + path: 'bun.lockb', + type: 'addition', + contents: newLock, + }, + }, + ]); + }); + + it('supports lockFileMaintenance', async () => { + updateArtifact.config.lockFiles = ['bun.lockb']; + updateArtifact.config.updateType = 'lockFileMaintenance'; + const oldLock = Buffer.from('old'); + fs.readFile.mockResolvedValueOnce(oldLock as never); + const newLock = Buffer.from('new'); + fs.readFile.mockResolvedValueOnce(newLock as never); + expect(await updateArtifacts(updateArtifact)).toEqual([ + { + file: { + path: 'bun.lockb', + type: 'addition', + contents: newLock, + }, + }, + ]); + }); + + it('handles temporary error', async () => { + const execError = new ExecError(TEMPORARY_ERROR, { + cmd: '', + stdout: '', + stderr: '', + options: { encoding: 'utf8' }, + }); + updateArtifact.updatedDeps = [{}]; + updateArtifact.config.lockFiles = ['bun.lockb']; + const oldLock = Buffer.from('old'); + fs.readFile.mockResolvedValueOnce(oldLock as never); + exec.mockRejectedValueOnce(execError); + await expect(updateArtifacts(updateArtifact)).rejects.toThrow( + TEMPORARY_ERROR + ); + }); + + it('handles full error', async () => { + const execError = new ExecError('nope', { + cmd: '', + stdout: '', + stderr: '', + options: { encoding: 'utf8' }, + }); + updateArtifact.updatedDeps = [{}]; + updateArtifact.config.lockFiles = ['bun.lockb']; + const oldLock = Buffer.from('old'); + fs.readFile.mockResolvedValueOnce(oldLock as never); + exec.mockRejectedValueOnce(execError); + expect(await updateArtifacts(updateArtifact)).toEqual([ + { artifactError: { lockFile: 'bun.lockb', stderr: 'nope' } }, + ]); + }); + }); +}); diff --git a/lib/modules/manager/bun/artifacts.ts b/lib/modules/manager/bun/artifacts.ts new file mode 100644 index 0000000000000000000000000000000000000000..7fdef867e659db8e4e86db7f032d23dd2a9336d2 --- /dev/null +++ b/lib/modules/manager/bun/artifacts.ts @@ -0,0 +1,87 @@ +import is from '@sindresorhus/is'; +import { TEMPORARY_ERROR } from '../../../constants/error-messages'; +import { logger } from '../../../logger'; +import { exec } from '../../../util/exec'; +import type { ExecOptions } from '../../../util/exec/types'; +import { + deleteLocalFile, + readLocalFile, + writeLocalFile, +} from '../../../util/fs'; +import type { UpdateArtifact, UpdateArtifactsResult } from '../types'; + +export async function updateArtifacts( + updateArtifact: UpdateArtifact +): Promise<UpdateArtifactsResult[] | null> { + const { packageFileName, updatedDeps, newPackageFileContent, config } = + updateArtifact; + logger.debug({ updateArtifact }, `bun.updateArtifacts(${packageFileName})`); + const isLockFileMaintenance = config.updateType === 'lockFileMaintenance'; + + if (is.emptyArray(updatedDeps) && !isLockFileMaintenance) { + logger.debug('No updated bun deps - returning null'); + return null; + } + + const lockFileName = config.lockFiles?.[0]; + + if (!lockFileName) { + logger.debug(`No ${lockFileName} found`); + return null; + } + + const oldLockFileContent = await readLocalFile(lockFileName); + if (!oldLockFileContent) { + logger.debug(`No ${lockFileName} found`); + return null; + } + + try { + await writeLocalFile(packageFileName, newPackageFileContent); + if (isLockFileMaintenance) { + await deleteLocalFile(lockFileName); + } + + const execOptions: ExecOptions = { + cwdFile: packageFileName, + docker: {}, + toolConstraints: [ + { + toolName: 'bun', + constraint: updateArtifact?.config?.constraints?.bun, + }, + ], + }; + + await exec('bun install', execOptions); + const newLockFileContent = await readLocalFile(lockFileName); + if ( + !newLockFileContent || + Buffer.compare(oldLockFileContent, newLockFileContent) === 0 + ) { + return null; + } + return [ + { + file: { + type: 'addition', + path: lockFileName, + contents: newLockFileContent, + }, + }, + ]; + } catch (err) { + if (err.message === TEMPORARY_ERROR) { + throw err; + } + logger.warn({ lockfile: lockFileName, err }, `Failed to update lock file`); + return [ + { + artifactError: { + lockFile: lockFileName, + stderr: err.message, + }, + }, + ]; + } +} diff --git a/lib/modules/manager/bun/extract.spec.ts b/lib/modules/manager/bun/extract.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..15411f0f492f3f6a67aff9df8531354c6144bccf --- /dev/null +++ b/lib/modules/manager/bun/extract.spec.ts @@ -0,0 +1,68 @@ +import { fs } from '../../../../test/util'; +import { extractAllPackageFiles } from './extract'; + +jest.mock('../../../util/fs'); + +describe('modules/manager/bun/extract', () => { + describe('extractAllPackageFiles()', () => { + it('ignores non-bun files', async () => { + expect(await extractAllPackageFiles({}, ['package.json'])).toEqual([]); + }); + + it('ignores missing package.json file', async () => { + expect(await extractAllPackageFiles({}, ['bun.lockb'])).toEqual([]); + }); + + it('ignores invalid package.json file', async () => { + (fs.readLocalFile as jest.Mock).mockResolvedValueOnce('invalid'); + expect(await extractAllPackageFiles({}, ['bun.lockb'])).toEqual([]); + }); + + it('handles null response', async () => { + fs.getSiblingFileName.mockReturnValueOnce('package.json'); + fs.readLocalFile.mockResolvedValueOnce( + // This package.json returns null from the extractor + JSON.stringify({ + _id: 1, + _args: 1, + _from: 1, + }) + ); + expect(await extractAllPackageFiles({}, ['bun.lockb'])).toEqual([]); + }); + + it('parses valid package.json file', async () => { + fs.getSiblingFileName.mockReturnValueOnce('package.json'); + fs.readLocalFile.mockResolvedValueOnce( + JSON.stringify({ + name: 'test', + version: '0.0.1', + dependencies: { + dep1: '1.0.0', + }, + }) + ); + expect(await extractAllPackageFiles({}, ['bun.lockb'])).toMatchObject([ + { + deps: [ + { + currentValue: '1.0.0', + datasource: 'npm', + depName: 'dep1', + depType: 'dependencies', + prettyDepType: 'dependency', + }, + ], + extractedConstraints: {}, + lockFiles: ['bun.lockb'], + managerData: { + hasPackageManager: false, + packageJsonName: 'test', + }, + packageFile: 'package.json', + packageFileVersion: '0.0.1', + }, + ]); + }); + }); +}); diff --git a/lib/modules/manager/bun/extract.ts b/lib/modules/manager/bun/extract.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd0514f122f34292d030e1f9ad56150934a34565 --- /dev/null +++ b/lib/modules/manager/bun/extract.ts @@ -0,0 +1,53 @@ +import { logger } from '../../../logger'; +import { getSiblingFileName, readLocalFile } from '../../../util/fs'; + +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'; + +function matchesFileName(fileNameWithPath: string, fileName: string): boolean { + return ( + fileNameWithPath === fileName || fileNameWithPath.endsWith(`/${fileName}`) + ); +} + +export async function extractAllPackageFiles( + config: ExtractConfig, + matchedFiles: string[] +): Promise<PackageFile[]> { + const packageFiles: PackageFile<NpmManagerData>[] = []; + for (const matchedFile of matchedFiles) { + if (!matchesFileName(matchedFile, 'bun.lockb')) { + logger.warn({ matchedFile }, 'Invalid bun lockfile match'); + continue; + } + const packageFile = getSiblingFileName(matchedFile, 'package.json'); + const packageFileContent = await readLocalFile(packageFile, 'utf8'); + if (!packageFileContent) { + logger.debug({ packageFile }, 'No package.json found'); + continue; + } + + let packageJson: NpmPackage; + try { + packageJson = JSON.parse(packageFileContent); + } catch (err) { + logger.debug({ err }, 'Error parsing package.json'); + continue; + } + + const extracted = extractPackageJson(packageJson, packageFile); + if (!extracted) { + logger.debug({ packageFile }, 'No dependencies found'); + continue; + } + packageFiles.push({ + ...extracted, + packageFile, + lockFiles: [matchedFile], + }); + } + + return packageFiles; +} diff --git a/lib/modules/manager/bun/index.ts b/lib/modules/manager/bun/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..22ad5d6ce1212d87c07d9e5c65d9fe7ef83e5018 --- /dev/null +++ b/lib/modules/manager/bun/index.ts @@ -0,0 +1,30 @@ +import type { Category } from '../../../constants'; +import { GithubTagsDatasource } from '../../datasource/github-tags'; +import { NpmDatasource } from '../../datasource/npm'; +import * as npmVersioning from '../../versioning/npm'; + +export { updateArtifacts } from './artifacts'; +export { extractAllPackageFiles } from './extract'; +export { getRangeStrategy, updateDependency } from '../npm'; + +export const supersedesManagers = ['npm']; +export const supportsLockFileMaintenance = true; + +export const defaultConfig = { + fileMatch: ['(^|/)bun\\.lockb$'], + versioning: npmVersioning.id, + digest: { + prBodyDefinitions: { + Change: + '{{#if displayFrom}}`{{{displayFrom}}}` -> {{else}}{{#if currentValue}}`{{{currentValue}}}` -> {{/if}}{{/if}}{{#if displayTo}}`{{{displayTo}}}`{{else}}`{{{newValue}}}`{{/if}}', + }, + }, + prBodyDefinitions: { + Change: + "[{{#if displayFrom}}`{{{displayFrom}}}` -> {{else}}{{#if currentValue}}`{{{currentValue}}}` -> {{/if}}{{/if}}{{#if displayTo}}`{{{displayTo}}}`{{else}}`{{{newValue}}}`{{/if}}]({{#if depName}}https://renovatebot.com/diffs/npm/{{replace '/' '%2f' depName}}/{{{currentVersion}}}/{{{newVersion}}}{{/if}})", + }, +}; + +export const categories: Category[] = ['js']; + +export const supportedDatasources = [GithubTagsDatasource.id, NpmDatasource.id]; diff --git a/lib/modules/manager/bun/readme.md b/lib/modules/manager/bun/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..0fa2f420f7fb95db97e16f2a57a55f4e0a987909 --- /dev/null +++ b/lib/modules/manager/bun/readme.md @@ -0,0 +1,5 @@ +Used for updating bun projects. +Bun is a tool for JavaScript projects and therefore an alternative to managers like npm, pnpm and Yarn. + +If a `package.json` is found to be part of `bun` manager results then the same file will be excluded from the `npm` manager results so that it's not duplicated. +This means that supporting a `bun.lockb` file in addition to other JS lock files is not supported - Bun will take priority. diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index 8cfdba235b92ee67b1ff908940e563fa2a0f7146..ad5b615a6517e39c68f6c1a6a47960f87425378a 100644 --- a/lib/modules/manager/types.ts +++ b/lib/modules/manager/types.ts @@ -37,6 +37,7 @@ export interface UpdateArtifactsConfig { newVersion?: string; newMajor?: number; registryAliases?: Record<string, string>; + lockFiles?: string[]; } export interface RangeConfig<T = Record<string, any>> extends ManagerData<T> { @@ -226,7 +227,7 @@ export interface ManagerApi extends ModuleApi { categories?: Category[]; supportsLockFileMaintenance?: boolean; - + supersedesManagers?: string[]; supportedDatasources: string[]; bumpPackageVersion?( diff --git a/lib/util/exec/containerbase.ts b/lib/util/exec/containerbase.ts index 6571d1850b8b98df05caa7ac21cd136b2e798c32..c643536b3df27f50ac8840823a8f8a8efa91948e 100644 --- a/lib/util/exec/containerbase.ts +++ b/lib/util/exec/containerbase.ts @@ -17,6 +17,12 @@ import { id as semverCoercedVersioningId } from '../../modules/versioning/semver import type { Opt, ToolConfig, ToolConstraint } from './types'; const allToolConfig: Record<string, ToolConfig> = { + bun: { + datasource: 'github-releases', + packageName: 'oven-sh/bun', + extractVersion: '^bun-v(?<version>.*)$', + versioning: npmVersioningId, + }, bundler: { datasource: 'rubygems', packageName: 'bundler', diff --git a/lib/workers/repository/extract/index.ts b/lib/workers/repository/extract/index.ts index bcf281bb5f9071e8a86c6ba92c7f8f6c28b117cb..04cdc3200a29a35b1ce84402aa3aa62ef6813e03 100644 --- a/lib/workers/repository/extract/index.ts +++ b/lib/workers/repository/extract/index.ts @@ -8,6 +8,7 @@ import { scm } from '../../../modules/platform/scm'; import type { ExtractResult, WorkerExtractConfig } from '../../types'; import { getMatchingFiles } from './file-match'; import { getManagerPackageFiles } from './manager-files'; +import { processSupersedesManagers } from './supersedes'; export async function extractAllDependencies( config: RenovateConfig @@ -66,6 +67,10 @@ export async function extractAllDependencies( return { manager: managerConfig.manager, packageFiles }; }) ); + + // De-duplicate results using supersedesManagers + processSupersedesManagers(extractResults); + logger.debug( { managers: extractDurations }, 'manager extract durations (ms)' diff --git a/lib/workers/repository/extract/supersedes.spec.ts b/lib/workers/repository/extract/supersedes.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..247b74e52a067857befb65d6ad0b787540abc6ba --- /dev/null +++ b/lib/workers/repository/extract/supersedes.spec.ts @@ -0,0 +1,46 @@ +import { processSupersedesManagers } from './supersedes'; +import type { ExtractResults } from './types'; + +describe('workers/repository/extract/supersedes', () => { + describe('processSupercedesManagers', () => { + it('handles empty extractResults', () => { + const extractResults: ExtractResults[] = []; + processSupersedesManagers(extractResults); + expect(extractResults).toHaveLength(0); + }); + + it('handles supercedes subset', () => { + const extractResults: ExtractResults[] = [ + { manager: 'ansible' }, + { + manager: 'bun', + packageFiles: [{ packageFile: 'package.json', deps: [] }], + }, + { + manager: 'npm', + packageFiles: [ + { packageFile: 'package.json', deps: [] }, + { packageFile: 'backend/package.json', deps: [] }, + ], + }, + ]; + processSupersedesManagers(extractResults); + expect(extractResults).toMatchObject([ + { manager: 'ansible' }, + { + manager: 'bun', + packageFiles: [ + { + deps: [], + packageFile: 'package.json', + }, + ], + }, + { + manager: 'npm', + packageFiles: [{ deps: [], packageFile: 'backend/package.json' }], + }, + ]); + }); + }); +}); diff --git a/lib/workers/repository/extract/supersedes.ts b/lib/workers/repository/extract/supersedes.ts new file mode 100644 index 0000000000000000000000000000000000000000..560d6bb0c50d65d460638c18130d4b60dbac4567 --- /dev/null +++ b/lib/workers/repository/extract/supersedes.ts @@ -0,0 +1,35 @@ +import is from '@sindresorhus/is'; +import { get } from '../../../modules/manager'; +import type { ExtractResults } from './types'; + +export function processSupersedesManagers( + extractResults: ExtractResults[] +): void { + for (const { manager, packageFiles } of extractResults) { + if (!packageFiles) { + continue; + } + const supersedesManagers = get(manager, 'supersedesManagers'); + if (is.nonEmptyArray(supersedesManagers)) { + const supercedingPackageFileNames = packageFiles.map( + (packageFile) => packageFile.packageFile + ); + for (const supercededManager of supersedesManagers) { + const supercededManagerResults = extractResults.find( + (result) => result.manager === supercededManager + ); + if (supercededManagerResults?.packageFiles) { + supercededManagerResults.packageFiles = + supercededManagerResults.packageFiles.filter((packageFile) => { + if ( + supercedingPackageFileNames.includes(packageFile.packageFile) + ) { + return false; + } + return true; + }); + } + } + } + } +} diff --git a/lib/workers/repository/extract/types.ts b/lib/workers/repository/extract/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..4526d46e8f8b54f3cd466d9ee4d9d1b37ac217b1 --- /dev/null +++ b/lib/workers/repository/extract/types.ts @@ -0,0 +1,6 @@ +import type { PackageFile } from '../../../modules/manager/types'; + +export interface ExtractResults { + manager: string; + packageFiles?: PackageFile[] | null; +}