diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts index 573fcf95375e93..259f893928a03c 100644 --- a/lib/modules/manager/api.ts +++ b/lib/modules/manager/api.ts @@ -28,6 +28,7 @@ import * as copier from './copier'; import * as cpanfile from './cpanfile'; import * as crossplane from './crossplane'; import * as depsEdn from './deps-edn'; +import * as devbox from './devbox'; import * as devContainer from './devcontainer'; import * as dockerCompose from './docker-compose'; import * as dockerfile from './dockerfile'; @@ -135,6 +136,7 @@ api.set('copier', copier); api.set('cpanfile', cpanfile); api.set('crossplane', crossplane); api.set('deps-edn', depsEdn); +api.set('devbox', devbox); api.set('devcontainer', devContainer); api.set('docker-compose', dockerCompose); api.set('dockerfile', dockerfile); diff --git a/lib/modules/manager/devbox/artifacts.spec.ts b/lib/modules/manager/devbox/artifacts.spec.ts new file mode 100644 index 00000000000000..f2ffbd4dcfc2de --- /dev/null +++ b/lib/modules/manager/devbox/artifacts.spec.ts @@ -0,0 +1,243 @@ +import { codeBlock } from 'common-tags'; +import { mockExecAll } from '../../../../test/exec-util'; +import { fs, git, partial } from '../../../../test/util'; +import { GlobalConfig } from '../../../config/global'; +import type { RepoGlobalConfig } from '../../../config/types'; +import type { StatusResult } from '../../../util/git/types'; +import type { UpdateArtifact } from '../types'; +import { updateArtifacts } from './artifacts'; + +jest.mock('../../../util/exec/env'); +jest.mock('../../../util/git'); +jest.mock('../../../util/fs'); + +const globalConfig: RepoGlobalConfig = { + localDir: '', +}; + +const devboxJson = codeBlock` + { + "$schema": "https://raw.githubusercontent.com/jetpack-io/devbox/0.10.1/.schema/devbox.schema.json", + "packages": ["nodejs@20", "metabase@0.49.1", "postgresql@latest", "gh@latest"], + } +`; + +describe('modules/manager/devbox/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 = [ + { manager: 'devbox', lockFiles: ['devbox.lock'] }, + ]; + expect(await updateArtifacts(updateArtifact)).toBeNull(); + }); + + it('returns installed devbox.lock', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + const execSnapshots = mockExecAll(); + const oldLockFileContent = Buffer.from('Old devbox.lock'); + const newLockFileContent = Buffer.from('New devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); + fs.readLocalFile.mockResolvedValueOnce(newLockFileContent as never); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [{ manager: 'devbox', lockFiles: ['devbox.lock'] }], + config: {}, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'devbox.lock', + contents: newLockFileContent, + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'devbox install', + options: { + cwd: '.', + encoding: 'utf-8', + env: {}, + maxBuffer: 10485760, + timeout: 900000, + }, + }, + ]); + }); + + it('returns updated devbox.lock', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + const execSnapshots = mockExecAll(); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: ['devbox.lock'], + }), + ); + const oldLockFileContent = Buffer.from('old devbox.lock'); + const newLockFileContent = Buffer.from('New devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); + fs.readLocalFile.mockResolvedValueOnce(newLockFileContent as never); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [{}], + config: { + isLockFileMaintenance: true, + }, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'devbox.lock', + contents: newLockFileContent, + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'devbox update', + options: { + cwd: '.', + encoding: 'utf-8', + env: {}, + maxBuffer: 10485760, + timeout: 900000, + }, + }, + ]); + }); + + it('returns null if no changes are found', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: [], + }), + ); + mockExecAll(); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [], + config: {}, + }), + ).toBeNull(); + }); + + it('returns null if devbox.lock not found after update', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: [], + }), + ); + mockExecAll(); + const oldLockFileContent = Buffer.from('Old devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [{}], + config: {}, + }), + ).toBeNull(); + }); + + it('returns null if devbox.lock not found', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: [], + }), + ); + mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(null); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [{}], + config: {}, + }), + ).toBeNull(); + }); + + it('returns null if no lock file changes are found', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: [], + }), + ); + mockExecAll(); + const oldLockFileContent = Buffer.from('Old devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [{}], + config: {}, + }), + ).toBeNull(); + }); + + it('returns an artifact error on failure', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + const newLockFileContent = codeBlock`{}`; + const oldLockFileContent = Buffer.from('New devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); + fs.readLocalFile.mockResolvedValueOnce(newLockFileContent as never); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [{}], + config: {}, + }), + ).toEqual([ + { + artifactError: { + lockFile: 'devbox.lock', + stderr: "Cannot read properties of undefined (reading 'stdout')", + }, + }, + ]); + }); + }); +}); diff --git a/lib/modules/manager/devbox/artifacts.ts b/lib/modules/manager/devbox/artifacts.ts new file mode 100644 index 00000000000000..c90c6faa3345e6 --- /dev/null +++ b/lib/modules/manager/devbox/artifacts.ts @@ -0,0 +1,82 @@ +import is from '@sindresorhus/is'; +import { logger } from '../../../logger'; +import { exec } from '../../../util/exec'; +import type { ExecOptions } from '../../../util/exec/types'; +import { getSiblingFileName, readLocalFile } from '../../../util/fs'; +import type { UpdateArtifact, UpdateArtifactsResult } from '../types'; + +export async function updateArtifacts( + updateConfig: UpdateArtifact, +): Promise { + const lockFileName = getSiblingFileName( + updateConfig.packageFileName, + 'devbox.lock', + ); + const existingLockFileContent = await readLocalFile(lockFileName, 'utf8'); + if (!existingLockFileContent) { + logger.debug('No devbox.lock found'); + return null; + } + const execOptions: ExecOptions = { + cwdFile: updateConfig.packageFileName, + toolConstraints: [ + { + toolName: 'devbox', + constraint: updateConfig.config.constraints?.devbox, + }, + ], + docker: {}, + userConfiguredEnv: updateConfig.config.env, + }; + + let cmd = ''; + if ( + updateConfig.config.isLockFileMaintenance || + updateConfig.config.updateType === 'lockFileMaintenance' + ) { + cmd += 'devbox update'; + } else if (is.nonEmptyArray(updateConfig.updatedDeps)) { + cmd += 'devbox install'; + } else { + logger.trace('No updated devbox packages - returning null'); + return null; + } + + const oldLockFileContent = await readLocalFile(lockFileName); + if (!oldLockFileContent) { + logger.trace(`No ${lockFileName} found`); + return null; + } + + try { + await exec(cmd, execOptions); + const newLockFileContent = await readLocalFile(lockFileName); + + if ( + !newLockFileContent || + Buffer.compare(oldLockFileContent, newLockFileContent) === 0 + ) { + return null; + } + logger.trace('Returning updated devbox.lock'); + return [ + { + file: { + type: 'addition', + path: lockFileName, + contents: newLockFileContent, + }, + }, + ]; + } catch (err) { + logger.warn({ err }, 'Error updating devbox.lock'); + return [ + { + artifactError: { + lockFile: lockFileName, + stderr: err.message, + }, + }, + ]; + } +} diff --git a/lib/modules/manager/devbox/extract.spec.ts b/lib/modules/manager/devbox/extract.spec.ts new file mode 100644 index 00000000000000..e97a2f470c2535 --- /dev/null +++ b/lib/modules/manager/devbox/extract.spec.ts @@ -0,0 +1,311 @@ +import { codeBlock } from 'common-tags'; +import { extractPackageFile } from '.'; + +describe('modules/manager/devbox/extract', () => { + describe('extractPackageFile', () => { + it('returns null when the devbox JSON file is empty', () => { + const result = extractPackageFile('', 'devbox.lock'); + expect(result).toBeNull(); + }); + + it('returns null when the devbox JSON file is malformed', () => { + const result = extractPackageFile('malformed json}}}}}', 'devbox.lock'); + expect(result).toBeNull(); + }); + + it('returns null when the devbox JSON file has no packages', () => { + const result = extractPackageFile('{}', 'devbox.lock'); + expect(result).toBeNull(); + }); + + it('returns a package dependency when the devbox JSON file has a single package', () => { + const result = extractPackageFile( + codeBlock` + { + "packages": ["nodejs@20.1.8"] + } + `, + 'devbox.lock', + ); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + }, + ], + }); + }); + + it('returns a package dependency when the devbox JSON file has a single package with a version object', () => { + const result = extractPackageFile( + codeBlock` + { + "packages": { + "nodejs": "20.1.8" + } + } + `, + 'devbox.lock', + ); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + }, + ], + }); + }); + + it('returns invalid-version when the devbox JSON file has a single package with an invalid version', () => { + const result = extractPackageFile( + codeBlock` + { + "packages": { + "nodejs": "^20.1.8" + } + } + `, + 'devbox.lock', + ); + expect(result).toEqual({ + deps: [ + { + currentValue: '^20.1.8', + datasource: 'devbox', + depName: 'nodejs', + packageName: 'nodejs', + skipReason: 'invalid-version', + }, + ], + }); + }); + + it('returns a package dependency when the devbox JSON file has multiple packages', () => { + const result = extractPackageFile( + codeBlock` + { + "packages": ["nodejs@20.1.8", "yarn@1.22.10"] + } + `, + 'devbox.lock', + ); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + }, + ], + }); + }); + + it('returns a package dependency when the devbox JSON file has multiple packages with in a packages object', () => { + const result = extractPackageFile( + codeBlock` + { + "packages": { + "nodejs": "20.1.8", + "yarn": "1.22.10" + } + } + `, + 'devbox.lock', + ); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + }, + ], + }); + }); + + it('returns a package dependency when the devbox JSON file has multiple packages with package objects', () => { + const result = extractPackageFile( + codeBlock` + { + "packages": { + "nodejs": { + "version": "20.1.8" + }, + "yarn": { + "version": "1.22.10" + } + } + } + `, + 'devbox.lock', + ); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + }, + ], + }); + }); + + it('returns invalid dependencies', () => { + const result = extractPackageFile( + codeBlock` + { + "packages": { + "nodejs": "20.1.8", + "yarn": "1.22.10", + "invalid": "invalid" + } + } + `, + 'devbox.lock', + ); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + }, + { + currentValue: 'invalid', + datasource: 'devbox', + depName: 'invalid', + packageName: 'invalid', + skipReason: 'invalid-version', + }, + ], + }); + }); + + it('returns invalid dependencies with package objects', () => { + const result = extractPackageFile( + codeBlock` + { + "packages": { + "nodejs": "20.1.8", + "yarn": "1.22.10", + "invalid": { + "version": "invalid" + } + } + } + `, + 'devbox.lock', + ); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + }, + { + currentValue: 'invalid', + datasource: 'devbox', + depName: 'invalid', + packageName: 'invalid', + skipReason: 'invalid-version', + }, + ], + }); + }); + + it('returns invalid dependencies from the packages array', () => { + const result = extractPackageFile( + codeBlock` + { + "packages": ["nodejs@20.1.8", "yarn@1.22.10", "invalid@invalid", "invalid2"] + } + `, + 'devbox.lock', + ); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + }, + { + currentValue: 'invalid', + datasource: 'devbox', + depName: 'invalid', + packageName: 'invalid', + skipReason: 'invalid-version', + }, + { + currentValue: undefined, + datasource: 'devbox', + depName: 'invalid2', + packageName: 'invalid2', + skipReason: 'not-a-version', + }, + ], + }); + }); + + it('returns null if there are no dependencies', () => { + const result = extractPackageFile( + codeBlock` + { + "packages": [] + } + `, + 'devbox.lock', + ); + expect(result).toBeNull(); + }); + }); +}); diff --git a/lib/modules/manager/devbox/extract.ts b/lib/modules/manager/devbox/extract.ts new file mode 100644 index 00000000000000..ab557ce7ea4b9e --- /dev/null +++ b/lib/modules/manager/devbox/extract.ts @@ -0,0 +1,27 @@ +import { logger } from '../../../logger'; +import type { PackageFileContent } from '../types'; +import { DevboxFileSchema } from './schema'; + +export function extractPackageFile( + content: string, + packageFile: string, +): PackageFileContent | null { + logger.trace('devbox.extractPackageFile()'); + + const parsedFile = DevboxFileSchema.safeParse(content); + if (parsedFile.error) { + logger.debug( + { packageFile, error: parsedFile.error }, + 'Error parsing devbox.json', + ); + return null; + } + + const deps = parsedFile.data.packages; + + if (deps.length) { + return { deps }; + } + + return null; +} diff --git a/lib/modules/manager/devbox/index.ts b/lib/modules/manager/devbox/index.ts new file mode 100644 index 00000000000000..d6764af34e43ef --- /dev/null +++ b/lib/modules/manager/devbox/index.ts @@ -0,0 +1,12 @@ +import { DevboxDatasource } from '../../datasource/devbox'; + +export { extractPackageFile } from './extract'; +export { updateArtifacts } from './artifacts'; + +export const supportsLockFileMaintenance = true; + +export const defaultConfig = { + fileMatch: ['(^|/)devbox\\.json$'], +}; + +export const supportedDatasources = [DevboxDatasource.id]; diff --git a/lib/modules/manager/devbox/readme.md b/lib/modules/manager/devbox/readme.md new file mode 100644 index 00000000000000..5b91520f9fed8b --- /dev/null +++ b/lib/modules/manager/devbox/readme.md @@ -0,0 +1,5 @@ +Used for updating [devbox](https://www.jetify.com/devbox) projects. + +Devbox is a tool for creating isolated, reproducible development environments that run anywhere. + +It uses nix packages sourced from the devbox package registry. diff --git a/lib/modules/manager/devbox/schema.spec.ts b/lib/modules/manager/devbox/schema.spec.ts new file mode 100644 index 00000000000000..02bd8b97ac537f --- /dev/null +++ b/lib/modules/manager/devbox/schema.spec.ts @@ -0,0 +1,69 @@ +import { DevboxSchema } from './schema'; + +describe('modules/manager/devbox/schema', () => { + it('parses devbox.json with empty packages', () => { + expect(DevboxSchema.parse({ packages: {} })).toEqual({ + packages: [], + }); + }); + + it.each([ + [ + 'parses devbox.json with packages record', + { packages: { foo: '1.2.3' } }, + { packages: { foo: '1.2.3', bar: '1.2.3' } }, + ], + [ + 'parses devbox.json with packages record with version key', + { + packages: { + foo: { + version: '1.2.3', + }, + }, + }, + { + packages: { + foo: { + version: '1.2.3', + }, + bar: { + version: '1.2.3', + }, + }, + }, + ], + [ + 'parses devbox.json with packages array', + { packages: ['foo@1.2.3'] }, + { packages: ['foo@1.2.3', 'bar@1.2.3'] }, + ], + ])('%s', (_, singleTest, multipleTest) => { + expect(DevboxSchema.parse(singleTest)).toEqual({ + packages: [ + { + currentValue: '1.2.3', + datasource: 'devbox', + depName: 'foo', + packageName: 'foo', + }, + ], + }); + expect(DevboxSchema.parse(multipleTest)).toEqual({ + packages: [ + { + currentValue: '1.2.3', + datasource: 'devbox', + depName: 'foo', + packageName: 'foo', + }, + { + currentValue: '1.2.3', + datasource: 'devbox', + depName: 'bar', + packageName: 'bar', + }, + ], + }); + }); +}); diff --git a/lib/modules/manager/devbox/schema.ts b/lib/modules/manager/devbox/schema.ts new file mode 100644 index 00000000000000..d96a4eb9417bb3 --- /dev/null +++ b/lib/modules/manager/devbox/schema.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; +import { logger } from '../../../logger'; +import { Jsonc } from '../../../util/schema-utils'; +import { DevboxDatasource } from '../../datasource/devbox'; +import * as devboxVersioning from '../../versioning/devbox'; +import type { PackageDependency } from '../types'; + +export const DevboxSchema = z.object({ + packages: z + .union([ + z.array(z.string()).transform((packages) => + packages.reduce( + (result, pkg) => { + const [name, version] = pkg.split('@'); + result[name] = version; + return result; + }, + {} as Record, + ), + ), + z.record( + z.union([ + z.string(), + z.object({ version: z.string() }).transform(({ version }) => version), + ]), + ), + ]) + .transform((packages): PackageDependency[] => + Object.entries(packages) + .map(([pkgName, pkgVer]) => getDep(pkgName, pkgVer)) + .filter((pkgDep): pkgDep is PackageDependency => !!pkgDep), + ), +}); + +export const DevboxFileSchema = Jsonc.pipe(DevboxSchema); + +function getDep( + packageName: string, + version: string | undefined, +): PackageDependency { + const dep = { + depName: packageName, + currentValue: version, + datasource: DevboxDatasource.id, + packageName, + }; + if (!dep.currentValue) { + logger.trace( + { packageName }, + 'Skipping devbox dependency with no version in devbox JSON file.', + ); + return { + ...dep, + skipReason: 'not-a-version', + }; + } + if (!devboxVersioning.api.isValid(dep.currentValue)) { + logger.trace( + { packageName }, + 'Skipping invalid devbox dependency in devbox JSON file.', + ); + return { + ...dep, + skipReason: 'invalid-version', + }; + } + return dep; +}