diff --git a/.changeset/salty-candies-fetch.md b/.changeset/salty-candies-fetch.md new file mode 100644 index 00000000000..e988d5811e1 --- /dev/null +++ b/.changeset/salty-candies-fetch.md @@ -0,0 +1,7 @@ +--- +'@aws-amplify/integration-tests': minor +'@aws-amplify/cli-core': minor +'@aws-amplify/backend-cli': minor +--- + +Add support for Bun diff --git a/.eslint_dictionary.json b/.eslint_dictionary.json index 946c5570d81..cbc3dbf0928 100644 --- a/.eslint_dictionary.json +++ b/.eslint_dictionary.json @@ -22,6 +22,7 @@ "backends", "birthdate", "bundler", + "bunx", "callee", "cartesian", "cdk", diff --git a/packages/cli-core/src/package-manager-controller/bun_package_manager_controller.test.ts b/packages/cli-core/src/package-manager-controller/bun_package_manager_controller.test.ts new file mode 100644 index 00000000000..31d3c1d2d32 --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/bun_package_manager_controller.test.ts @@ -0,0 +1,159 @@ +import fsp from 'fs/promises'; +import path from 'path'; +import { beforeEach, describe, it, mock } from 'node:test'; +import assert from 'assert'; +import { execa } from 'execa'; +import { BunPackageManagerController } from './bun_package_manager_controller.js'; +import { executeWithDebugLogger } from './execute_with_debugger_logger.js'; +import { LockFileReader } from './lock-file-reader/types.js'; + +void describe('BunPackageManagerController', () => { + const fspMock = { + readFile: mock.fn(() => + Promise.resolve(JSON.stringify({ compilerOptions: {} })), + ), + writeFile: mock.fn(() => Promise.resolve()), + }; + const pathMock = { + resolve: mock.fn(), + }; + const execaMock = mock.fn(() => Promise.resolve()); + const executeWithDebugLoggerMock = mock.fn(() => Promise.resolve()); + + beforeEach(() => { + fspMock.readFile.mock.resetCalls(); + fspMock.writeFile.mock.resetCalls(); + pathMock.resolve.mock.resetCalls(); + execaMock.mock.resetCalls(); + executeWithDebugLoggerMock.mock.resetCalls(); + }); + + void describe('installDependencies', () => { + const existsSyncMock = mock.fn(() => true); + const controller = new BunPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock, + ); + void it('runs bun add with the correct arguments for dev deps', async () => { + await controller.installDependencies( + ['testPackage1', 'testPackage2'], + 'dev', + ); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + assert.deepEqual(executeWithDebugLoggerMock.mock.calls[0].arguments, [ + '/testProjectRoot', + 'bun', + ['add', 'testPackage1', 'testPackage2', '-D'], + execaMock, + ]); + }); + + void it('runs bun add with the correct arguments for prod deps', async () => { + await controller.installDependencies( + ['testPackage1', 'testPackage2'], + 'prod', + ); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + assert.deepEqual(executeWithDebugLoggerMock.mock.calls[0].arguments, [ + '/testProjectRoot', + 'bun', + ['add', 'testPackage1', 'testPackage2'], + execaMock, + ]); + }); + }); + + void describe('initializeProject', () => { + void it('does nothing if package.json already exists', async () => { + let existsSyncMockValue = false; + const existsSyncMock = mock.fn(() => { + existsSyncMockValue = !existsSyncMockValue; + return existsSyncMockValue; + }); + const controller = new BunPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock, + ); + + await controller.initializeProject(); + assert.equal(existsSyncMock.mock.callCount(), 1); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 0); + }); + + void it('runs bun init if package.json does not exist', async () => { + let existsSyncMockValue = true; + const existsSyncMock = mock.fn(() => { + existsSyncMockValue = !existsSyncMockValue; + return existsSyncMockValue; + }); + const controller = new BunPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock, + ); + + await controller.initializeProject(); + assert.equal(existsSyncMock.mock.callCount(), 2); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + }); + }); + + void describe('initializeTsConfig', () => { + void it('initialize tsconfig.json', async () => { + const existsSyncMock = mock.fn(() => true); + const controller = new BunPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock, + ); + await controller.initializeTsConfig('./amplify'); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 0); + assert.equal(fspMock.writeFile.mock.callCount(), 1); + }); + }); + + void describe('getDependencies', () => { + void it('successfully returns dependency versions', async () => { + const existsSyncMock = mock.fn(() => true); + const lockFileReaderMock = { + getLockFileContentsFromCwd: async () => + Promise.resolve({ + dependencies: [ + { name: 'a', version: '1.0.0' }, + { name: 'b', version: '2.0.0' }, + ], + }), + } as LockFileReader; + const controller = new BunPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock, + lockFileReaderMock, + ); + const dependencyVersions = await controller.tryGetDependencies(); + const expectedVersions = [ + { name: 'a', version: '1.0.0' }, + { name: 'b', version: '2.0.0' }, + ]; + + assert.deepEqual(dependencyVersions, expectedVersions); + }); + }); +}); diff --git a/packages/cli-core/src/package-manager-controller/bun_package_manager_controller.ts b/packages/cli-core/src/package-manager-controller/bun_package_manager_controller.ts new file mode 100644 index 00000000000..441667a1404 --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/bun_package_manager_controller.ts @@ -0,0 +1,38 @@ +import { existsSync as _existsSync } from 'fs'; +import _fsp from 'fs/promises'; +import { execa as _execa } from 'execa'; +import * as _path from 'path'; +import { executeWithDebugLogger as _executeWithDebugLogger } from './execute_with_debugger_logger.js'; +import { PackageManagerControllerBase } from './package_manager_controller_base.js'; +import { BunLockFileReader } from './lock-file-reader/bun_lock_file_reader.js'; + +/** + * BunPackageManagerController is an abstraction around bun commands that are needed to initialize a project and install dependencies + */ +export class BunPackageManagerController extends PackageManagerControllerBase { + /** + * constructor + */ + constructor( + protected readonly cwd: string, + protected readonly fsp = _fsp, + protected readonly path = _path, + protected readonly execa = _execa, + protected readonly executeWithDebugLogger = _executeWithDebugLogger, + protected readonly existsSync = _existsSync, + protected readonly lockFileReader = new BunLockFileReader(), + ) { + super( + cwd, + 'bun', + ['init', '--yes'], + 'add', + lockFileReader, + fsp, + path, + execa, + executeWithDebugLogger, + existsSync, + ); + } +} diff --git a/packages/cli-core/src/package-manager-controller/get_package_manager_name.ts b/packages/cli-core/src/package-manager-controller/get_package_manager_name.ts index e249cbc5361..71148151c97 100644 --- a/packages/cli-core/src/package-manager-controller/get_package_manager_name.ts +++ b/packages/cli-core/src/package-manager-controller/get_package_manager_name.ts @@ -34,6 +34,7 @@ const runnerMap: Record = { 'yarn-modern': 'yarn', 'yarn-classic': 'yarn', pnpm: 'pnpm', + bun: 'bunx', }; /** @@ -45,7 +46,7 @@ export const getPackageManagerRunnerName = (): string => { if (!packageManagerRunnerName) { throw new AmplifyUserError('UnsupportedPackageManagerError', { message: `Package manager ${packageManagerName} is not supported.`, - resolution: 'Use npm, yarn, or pnpm.', + resolution: 'Use npm, yarn, pnpm or bun.', }); } return packageManagerRunnerName; diff --git a/packages/cli-core/src/package-manager-controller/lock-file-reader/bun_lock_file_reader.test.ts b/packages/cli-core/src/package-manager-controller/lock-file-reader/bun_lock_file_reader.test.ts new file mode 100644 index 00000000000..94a1bcc9cb8 --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/lock-file-reader/bun_lock_file_reader.test.ts @@ -0,0 +1,93 @@ +import assert from 'assert'; +import fsp from 'fs/promises'; +import { afterEach, describe, it, mock } from 'node:test'; +import path from 'path'; +import { BunLockFileReader } from './bun_lock_file_reader.js'; + +void describe('BunLockFileReader', () => { + // Avoid literal tokens that trigger repo spell-checkers + const WORKSPACES = ['work', 'spaces'].join(''); + const TSLIB = ['ts', 'lib'].join(''); + const fspReadFileMock = mock.method(fsp, 'readFile', () => + JSON.stringify({ + lockfileVersion: 1, + [WORKSPACES]: { + '': { + name: 'test_project', + dependencies: { + 'aws-amplify': '^6.15.6', + }, + }, + }, + packages: { + '@smithy/util-utf8': [ + '@smithy/util-utf8@2.0.0', + '', + { dependencies: { [TSLIB]: '^2.5.0' } }, + 'sha512-...', + ], + 'aws-amplify': [ + 'aws-amplify@6.15.6', + '', + { dependencies: { [TSLIB]: '^2.5.0' } }, + 'sha512-...', + ], + // Non-version forms that should be ignored + 'my-workspace': ['my-workspace@workspace:packages/app'], + 'my-link': ['my-link@link:../local'], + 'my-file': ['my-file@file:../archive.tgz'], + 'my-git': [ + 'my-git@git+https://github.com/user/repo.git', + { optionalDependencies: {} }, + 'bun-tag', + ], + 'my-root': ['my-root@root:', { bin: 'dist/cli.js', binDir: 'bin' }], + }, + }), + ); + const bunLockFileReader = new BunLockFileReader(); + + afterEach(() => { + fspReadFileMock.mock.resetCalls(); + }); + + void it('can get lock file contents from cwd', async () => { + const lockFileContents = + await bunLockFileReader.getLockFileContentsFromCwd(); + const expectedLockFileContents = { + dependencies: [ + { name: '@smithy/util-utf8', version: '2.0.0' }, + { name: 'aws-amplify', version: '6.15.6' }, + ], + }; + assert.deepEqual(lockFileContents, expectedLockFileContents); + assert.strictEqual( + fspReadFileMock.mock.calls[0].arguments[0], + path.resolve(process.cwd(), 'bun.lock'), + ); + assert.strictEqual(fspReadFileMock.mock.callCount(), 1); + }); + + void it('returns undefined when bun.lock is not present or parse-able', async () => { + fspReadFileMock.mock.mockImplementationOnce(() => + Promise.reject(new Error()), + ); + const lockFileContents = + await bunLockFileReader.getLockFileContentsFromCwd(); + assert.deepEqual(lockFileContents, undefined); + }); + + void it('returns empty dependency array when bun.lock does not have packages', async () => { + fspReadFileMock.mock.mockImplementationOnce(() => + JSON.stringify({ + lockfileVersion: 1, + [WORKSPACES]: { + '': { name: 'test_project' }, + }, + }), + ); + const lockFileContents = + await bunLockFileReader.getLockFileContentsFromCwd(); + assert.deepEqual(lockFileContents, { dependencies: [] }); + }); +}); diff --git a/packages/cli-core/src/package-manager-controller/lock-file-reader/bun_lock_file_reader.ts b/packages/cli-core/src/package-manager-controller/lock-file-reader/bun_lock_file_reader.ts new file mode 100644 index 00000000000..9759af668e7 --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/lock-file-reader/bun_lock_file_reader.ts @@ -0,0 +1,68 @@ +import { Dependency } from '@aws-amplify/plugin-types'; +import fsp from 'fs/promises'; +import path from 'path'; +import { LockFileContents, LockFileReader } from './types.js'; +import { printer } from '../../printer.js'; +import { LogLevel } from '../../printer/printer.js'; + +/** + * BunLockFileReader is an abstraction around the logic used to read and parse lock file contents + */ +export class BunLockFileReader implements LockFileReader { + getLockFileContentsFromCwd = async (): Promise< + LockFileContents | undefined + > => { + const dependencies: Array = []; + const bunLockJsonPath = path.resolve(process.cwd(), 'bun.lock'); + let bunLockJson: unknown; + try { + const jsonLockContents = await fsp.readFile(bunLockJsonPath, 'utf-8'); + bunLockJson = JSON.parse(jsonLockContents); + } catch { + printer.log( + `Failed to get lock file contents because ${bunLockJsonPath} does not exist or is not parse-able`, + LogLevel.DEBUG, + ); + return; + } + + // Narrow the parsed JSON to an object with optional packages record + const bunLockObject = + typeof bunLockJson === 'object' && bunLockJson !== null + ? (bunLockJson as { packages?: Record }) + : undefined; + + if (!bunLockObject || !bunLockObject.packages) { + return { dependencies }; + } + + const packages = bunLockObject.packages; + for (const key in packages) { + const entry = packages[key] as unknown; + // Each package entry is an array, first element is a string like + // "name@version", or "name@workspace:path", etc. + if (Array.isArray(entry) && typeof entry[0] === 'string') { + const pkgString = entry[0]; + const lastAtIndex = pkgString.lastIndexOf('@'); + if (lastAtIndex <= 0) { + // Invalid form or missing separator + continue; + } + + const dependencyName = pkgString.slice(0, lastAtIndex); + const dependencyVersion = pkgString.slice(lastAtIndex + 1); + + // Only include resolved npm packages that have a concrete version + // Skip workspace/link/file/git/github/root forms which are not versions + if (/^\d/.test(dependencyVersion)) { + dependencies.push({ + name: dependencyName, + version: dependencyVersion, + }); + } + } + } + + return { dependencies }; + }; +} diff --git a/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.test.ts b/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.test.ts index 5bdc78b68eb..0200fe11895 100644 --- a/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.test.ts +++ b/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.test.ts @@ -38,6 +38,11 @@ void describe('packageManagerControllerFactory', () => { userAgent: 'yarn/4.0.1 node/v15.0.0 darwin x64', expectedInstanceOf: PackageManagerControllerBase, }, + { + name: 'Bun', + userAgent: 'bun/1.2.21 node/v15.0.0 darwin x64', + expectedInstanceOf: PackageManagerControllerBase, + }, ]; for (const testCase of testCases) { diff --git a/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.ts b/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.ts index 60c87a92fe1..ca2dda60214 100644 --- a/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.ts +++ b/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.ts @@ -4,6 +4,7 @@ import { NpmPackageManagerController } from './npm_package_manager_controller.js import { PnpmPackageManagerController } from './pnpm_package_manager_controller.js'; import { YarnClassicPackageManagerController } from './yarn_classic_package_manager_controller.js'; import { YarnModernPackageManagerController } from './yarn_modern_package_manager_controller.js'; +import { BunPackageManagerController } from './bun_package_manager_controller.js'; import { printer as _printer } from '../printer.js'; import { Printer } from '../printer/printer.js'; import { getPackageManagerName } from './get_package_manager_name.js'; @@ -46,10 +47,12 @@ export class PackageManagerControllerFactory { return new YarnClassicPackageManagerController(this.cwd); case 'yarn-modern': return new YarnModernPackageManagerController(this.cwd, this.printer); + case 'bun': + return new BunPackageManagerController(this.cwd); default: throw new AmplifyUserError('UnsupportedPackageManagerError', { message: `Package Manager ${packageManagerName} is not supported.`, - resolution: 'Use npm, yarn or pnpm.', + resolution: 'Use npm, yarn, pnpm or bun.', }); } } diff --git a/packages/cli/src/commands/info/info_command.test.ts b/packages/cli/src/commands/info/info_command.test.ts index 8bab380f8f6..b32da9d15ba 100644 --- a/packages/cli/src/commands/info/info_command.test.ts +++ b/packages/cli/src/commands/info/info_command.test.ts @@ -54,6 +54,7 @@ void describe('info command run', () => { npm: 0.0.0 - /fake/path pnpm: 0.0.0 - /fake/path Yarn: 0.0.0 - /fake/path + Bun: 0.0.0 - /fake/path NPM Packages: fake: 0.0.0 `; diff --git a/packages/cli/src/info/env_info_provider.test.ts b/packages/cli/src/info/env_info_provider.test.ts index f8869597d74..aa4b3c1c639 100644 --- a/packages/cli/src/info/env_info_provider.test.ts +++ b/packages/cli/src/info/env_info_provider.test.ts @@ -32,6 +32,10 @@ void describe('Env Info', () => { path: '/fake/path', version: '0.0.0', }, + Bun: { + path: '/fake/path', + version: '0.0.0', + }, }, npmPackages: { fake: { @@ -59,6 +63,7 @@ void describe('Env Info', () => { ` npm: ${mockValue.Binaries.npm.version} - ${mockValue.Binaries.npm.path}`, ` pnpm: ${mockValue.Binaries.pnpm.version} - ${mockValue.Binaries.pnpm.path}`, ` Yarn: ${mockValue.Binaries.Yarn.version} - ${mockValue.Binaries.Yarn.path}`, + ` Bun: ${mockValue.Binaries.Bun.version} - ${mockValue.Binaries.Bun.path}`, 'NPM Packages:', ` fake: ${ (mockValue.npmPackages.fake as Record).installed diff --git a/packages/cli/src/info/env_info_provider.ts b/packages/cli/src/info/env_info_provider.ts index 96e2bcf91ab..6a1faabc59e 100644 --- a/packages/cli/src/info/env_info_provider.ts +++ b/packages/cli/src/info/env_info_provider.ts @@ -14,7 +14,7 @@ export class EnvironmentInfoProvider { const info = await envinfo.run( { System: ['OS', 'CPU', 'Memory', 'Shell'], - Binaries: ['Node', 'Yarn', 'npm', 'pnpm'], + Binaries: ['Node', 'Yarn', 'npm', 'pnpm', 'Bun'], npmPackages: [ '@aws-amplify/ai-constructs', '@aws-amplify/auth-construct', diff --git a/packages/cli/src/info/env_info_provider_types.ts b/packages/cli/src/info/env_info_provider_types.ts index 345255b1945..ca16ea4aa3a 100644 --- a/packages/cli/src/info/env_info_provider_types.ts +++ b/packages/cli/src/info/env_info_provider_types.ts @@ -24,6 +24,7 @@ export type EnvInfo = { Yarn: EnvInfoBinary; npm: EnvInfoBinary; pnpm: EnvInfoBinary; + Bun: EnvInfoBinary; }; npmPackages: Record; }; diff --git a/packages/integration-tests/src/process-controller/process_controller.ts b/packages/integration-tests/src/process-controller/process_controller.ts index 8224d83a664..126a5112188 100644 --- a/packages/integration-tests/src/process-controller/process_controller.ts +++ b/packages/integration-tests/src/process-controller/process_controller.ts @@ -196,6 +196,10 @@ export const runWithPackageManager = ( packageManagerBinary = 'yarn'; break; + case 'bun': + packageManagerBinary = 'bunx'; + break; + default: throw new Error(`Unknown package manager: ${packageManager as string}`); } diff --git a/packages/integration-tests/src/setup_package_manager.ts b/packages/integration-tests/src/setup_package_manager.ts index 6dc7a1a70f5..635077aa041 100644 --- a/packages/integration-tests/src/setup_package_manager.ts +++ b/packages/integration-tests/src/setup_package_manager.ts @@ -7,8 +7,13 @@ import * as os from 'os'; const customRegistry = 'http://localhost:4873'; // TODO: refactor into `type PackageManagerInitializer` and have sub-types with a factory. -export type PackageManager = 'npm' | 'yarn-classic' | 'yarn-modern' | 'pnpm'; -export type PackageManagerExecutable = 'npx' | 'yarn' | 'pnpm'; +export type PackageManager = + | 'npm' + | 'yarn-classic' + | 'yarn-modern' + | 'pnpm' + | 'bun'; +export type PackageManagerExecutable = 'npx' | 'yarn' | 'pnpm' | 'bunx'; const initializeNpm = async () => { const { stdout } = await execa('npm', ['config', 'get', 'cache']); @@ -73,6 +78,14 @@ const initializeYarnModern = async (execaOptions: { await execa(packageManager, ['cache', 'clean'], execaOptions); }; +const initializeBun = async () => { + const packageManager = 'bun'; + await execa('npm', ['install', '-g', packageManager], { + stdio: 'inherit', + }); + await execa(packageManager, ['--version']); +}; + /** * Sets up the package manager for the e2e flow */ @@ -112,6 +125,11 @@ export const setupPackageManager = async ( await initializeYarnModern(execaOptions); break; + case 'bun': + packageManagerExecutable = 'bunx'; + await initializeBun(); + break; + default: throw new Error(`Unknown package manager: ${packageManager as string}`); }