Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions .changeset/salty-candies-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@aws-amplify/integration-tests': minor
'@aws-amplify/cli-core': minor
'@aws-amplify/backend-cli': minor
---

Add support for Bun
1 change: 1 addition & 0 deletions .eslint_dictionary.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"backends",
"birthdate",
"bundler",
"bunx",
"callee",
"cartesian",
"cdk",
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const runnerMap: Record<string, string> = {
'yarn-modern': 'yarn',
'yarn-classic': 'yarn',
pnpm: 'pnpm',
bun: 'bunx',
};

/**
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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/[email protected]',
'',
{ dependencies: { [TSLIB]: '^2.5.0' } },
'sha512-...',
],
'aws-amplify': [
'[email protected]',
'',
{ 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: [] });
});
});
Original file line number Diff line number Diff line change
@@ -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<Dependency> = [];
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<string, unknown> })
: 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 };
};
}
Loading
Loading