diff --git a/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts b/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts index 9e87c8e3ccfb2..622685b373db4 100644 --- a/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts +++ b/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { IConstruct } from 'constructs'; @@ -286,9 +287,17 @@ export class Bundling implements cdk.BundlingOptions { const isPnpm = this.packageManager.lockFile === LockFile.PNPM; const isBun = this.packageManager.lockFile === LockFile.BUN_LOCK || this.packageManager.lockFile === LockFile.BUN; + // Copy .npmrc for pnpm to inherit private registry auth. Uses base64 to handle monorepos + // where .npmrc is outside projectRoot (inaccessible in Docker) and to avoid shell escaping issues. + const npmrcFilePath = isPnpm ? findUp('.npmrc', this.projectRoot) : undefined; + const npmrcBase64 = npmrcFilePath && fs.existsSync(npmrcFilePath) + ? Buffer.from(fs.readFileSync(npmrcFilePath, 'utf-8')).toString('base64') + : undefined; + // Create dummy package.json, copy lock file if any and then install depsCommand = chain([ isPnpm ? osCommand.write(pathJoin(options.outputDir, 'pnpm-workspace.yaml'), ''): '', // Ensure node_modules directory is installed locally by creating local 'pnpm-workspace.yaml' file + npmrcBase64 ? osCommand.writeBase64(pathJoin(options.outputDir, '.npmrc'), npmrcBase64) : '', // Write .npmrc for pnpm to inherit registry authentication osCommand.writeJson(pathJoin(options.outputDir, 'package.json'), { dependencies }), osCommand.copy(lockFilePath, pathJoin(options.outputDir, this.packageManager.lockFile)), osCommand.changeDirectory(options.outputDir), @@ -386,6 +395,13 @@ class OsCommand { return this.write(filePath, stringifiedData); } + public writeBase64(filePath: string, base64Data: string): string { + if (this.osPlatform === 'win32') { + return `powershell -Command "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${base64Data}')) | Set-Content -Path '${filePath}' -NoNewline"`; + } + return `echo '${base64Data}' | base64 -d > "${filePath}"`; + } + public copy(src: string, dest: string): string { if (this.osPlatform === 'win32') { return `copy "${src}" "${dest}"`; diff --git a/packages/aws-cdk-lib/aws-lambda-nodejs/test/.testnpmrc b/packages/aws-cdk-lib/aws-lambda-nodejs/test/.testnpmrc new file mode 100644 index 0000000000000..65b1af89185b6 --- /dev/null +++ b/packages/aws-cdk-lib/aws-lambda-nodejs/test/.testnpmrc @@ -0,0 +1 @@ +@myorg:registry=https://registry.example.com/ diff --git a/packages/aws-cdk-lib/aws-lambda-nodejs/test/bundling.test.ts b/packages/aws-cdk-lib/aws-lambda-nodejs/test/bundling.test.ts index 1cedebd09d7b3..f253e586991de 100644 --- a/packages/aws-cdk-lib/aws-lambda-nodejs/test/bundling.test.ts +++ b/packages/aws-cdk-lib/aws-lambda-nodejs/test/bundling.test.ts @@ -605,6 +605,113 @@ test('Detects pnpm-lock.yaml', () => { }); }); +test('pnpm with nodeModules writes .npmrc using base64 for private registry authentication', () => { + const pnpmLock = '/project/pnpm-lock.yaml'; + const npmrcFixture = path.join(__dirname, '.testnpmrc'); + const npmrcBase64 = Buffer.from(fs.readFileSync(npmrcFixture, 'utf-8')).toString('base64'); + + const originalFindUp = util.findUp; + jest.spyOn(util, 'findUp').mockImplementation((name, dir) => + name === '.npmrc' ? npmrcFixture : originalFindUp(name, dir), + ); + + Bundling.bundle(stack, { + entry: __filename, + projectRoot, + depsLockFilePath: pnpmLock, + runtime: STANDARD_RUNTIME, + architecture: Architecture.X86_64, + nodeModules: ['delay'], + forceDockerBundling: true, + }); + + expect(Code.fromAsset).toHaveBeenCalledWith(path.dirname(pnpmLock), { + assetHashType: AssetHashType.OUTPUT, + bundling: expect.objectContaining({ + command: expect.arrayContaining([ + expect.stringContaining(`echo '${npmrcBase64}' | base64 -d`), + ]), + }), + }); +}); + +test('Local bundling pnpm with .npmrc uses PowerShell base64 on win32', () => { + // Simulate Windows environment + const osPlatformMock = jest.spyOn(os, 'platform').mockReturnValue('win32'); + + // Mock spawnSync to capture the command that tryBundle() executes + const spawnSyncMock = jest.spyOn(child_process, 'spawnSync').mockReturnValue({ + status: 0, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + + const pnpmLock = path.join(__dirname, '..', 'pnpm-lock.yaml'); + const npmrcFixture = path.join(__dirname, '.testnpmrc'); + const npmrcBase64 = Buffer.from(fs.readFileSync(npmrcFixture, 'utf-8')).toString('base64'); + + const originalFindUp = util.findUp; + jest.spyOn(util, 'findUp').mockImplementation((name, dir) => + name === '.npmrc' ? npmrcFixture : originalFindUp(name, dir), + ); + + const bundler = new Bundling(stack, { + entry: __filename, + projectRoot: path.dirname(pnpmLock), + depsLockFilePath: pnpmLock, + runtime: STANDARD_RUNTIME, + architecture: Architecture.X86_64, + nodeModules: ['delay'], + }); + + // Call tryBundle() directly to test local bundling. + // Local bundling uses os.platform() to determine the shell (cmd on win32, bash on Linux). + // This is different from Docker bundling (forceDockerBundling: true) which always uses bash. + bundler.local?.tryBundle('/outdir', { image: STANDARD_RUNTIME.bundlingImage }); + + // Verify the command uses PowerShell to decode base64 on Windows + expect(spawnSyncMock).toHaveBeenCalledWith( + 'cmd', + ['/c', expect.stringContaining(`[System.Convert]::FromBase64String('${npmrcBase64}')`)], + expect.anything(), + ); + + osPlatformMock.mockRestore(); + spawnSyncMock.mockRestore(); +}); + +test('pnpm with nodeModules does not write .npmrc when none exists', () => { + const pnpmLock = '/project/pnpm-lock.yaml'; + + // Mock findUp to return undefined for .npmrc (simulating no .npmrc file found) + const originalFindUp = util.findUp; + jest.spyOn(util, 'findUp').mockImplementation((name, dir) => + name === '.npmrc' ? undefined : originalFindUp(name, dir), + ); + + Bundling.bundle(stack, { + entry: __filename, + projectRoot, + depsLockFilePath: pnpmLock, + runtime: STANDARD_RUNTIME, + architecture: Architecture.X86_64, + nodeModules: ['delay'], + forceDockerBundling: true, + }); + + // Verify the bundling command was generated + expect(Code.fromAsset).toHaveBeenCalledWith(path.dirname(pnpmLock), expect.anything()); + + // Extract the command and verify it does NOT contain base64 decoding for .npmrc + const call = (Code.fromAsset as jest.Mock).mock.calls[0]; + const bundlingCommand = call[1].bundling.command[2]; // The shell command string + expect(bundlingCommand).not.toContain('base64 -d'); + expect(bundlingCommand).not.toContain('.npmrc'); +}); + test('Detects bun.lockb', () => { const bunLock = path.join(__dirname, '..', 'bun.lockb'); Bundling.bundle(stack, {