diff --git a/cli/lib/archive.ts b/cli/lib/archive.ts index 31089ec91a..1c249af87b 100644 --- a/cli/lib/archive.ts +++ b/cli/lib/archive.ts @@ -1,7 +1,8 @@ import fs from 'fs'; +import fsPromises from 'fs/promises'; import path from 'path'; import { __ } from '@wordpress/i18n'; -import archiver, { EntryData } from 'archiver'; +import archiver from 'archiver'; import { LoggerError } from 'cli/logger'; const ZIP_COMPRESSION_LEVEL = 9; @@ -10,6 +11,11 @@ export async function createArchive( siteFolder: string, archivePath: string ): Promise< archiver.Archiver > { + const wpContentFolder = path.join( siteFolder, 'wp-content' ); + const directoryContents = await fsPromises.readdir( wpContentFolder, { + recursive: true, + } ); + return new Promise( ( resolve, reject ) => { const output = fs.createWriteStream( archivePath ); const archive = archiver( 'zip', { @@ -24,16 +30,19 @@ export async function createArchive( } ); archive.pipe( output ); - archive.directory( - path.join( siteFolder, 'wp-content' ), - 'wp-content', - ( entry: EntryData ) => { - if ( entry.name.includes( '.git' ) || entry.name.includes( 'node_modules' ) ) { - return false; - } - return entry; + + for ( const entryPath of directoryContents ) { + if ( entryPath.includes( '.git' ) || entryPath.includes( 'node_modules' ) ) { + continue; + } + + const absolutePath = path.join( wpContentFolder, entryPath ); + const filePath = getRealFilePathIfExists( absolutePath ); + const archivePath = path.relative( siteFolder, absolutePath ); + if ( filePath ) { + archive.file( filePath, { name: archivePath } ); } - ); + } const wpConfigPath = path.join( siteFolder, 'wp-config.php' ); if ( fs.existsSync( wpConfigPath ) ) { @@ -55,3 +64,21 @@ export async function cleanup( archivePath: string ): Promise< void > { }, 0 ); } ); } + +function getRealFilePathIfExists( absolutePath: string ): string | null { + const stat = fs.lstatSync( absolutePath ); + if ( stat.isFile() ) { + return absolutePath; + } + + if ( stat.isSymbolicLink() ) { + try { + return fs.realpathSync( absolutePath ); + } catch ( error ) { + // If there's an error resolving the symlink, return null + return null; + } + } + // Ignore directories + return null; +} diff --git a/cli/lib/tests/archive.test.ts b/cli/lib/tests/archive.test.ts index bb59ac32d0..0fc98842ba 100644 --- a/cli/lib/tests/archive.test.ts +++ b/cli/lib/tests/archive.test.ts @@ -1,9 +1,11 @@ import fs from 'fs'; +import fsPromises from 'fs/promises'; import path from 'path'; import archiver from 'archiver'; import { createArchive, cleanup } from 'cli/lib/archive'; jest.mock( 'fs' ); +jest.mock( 'fs/promises' ); jest.mock( 'path' ); jest.mock( 'archiver' ); @@ -12,6 +14,12 @@ describe( 'Archive Module', () => { const mockArchivePath = '/mock/archive.zip'; const mockWpContentPath = '/mock/site/folder/wp-content'; const mockWpConfigPath = '/mock/site/folder/wp-config.php'; + const mockDirectoryContents = [ + '/mock/site/wp-content/plugins/my-plugin', + '/mock/site/wp-content/plugins/my-plugin/my-plugin.php', + '/mock/site/wp-content/plugins/my-symlinked-plugin.php', + ]; + const mockSymLinkedFilePath = '/mock/temp/symlinked-file.txt'; const mockArchiver = { pipe: jest.fn(), @@ -28,8 +36,12 @@ describe( 'Archive Module', () => { beforeEach( () => { jest.clearAllMocks(); ( archiver as unknown as jest.Mock ).mockReturnValue( mockArchiver ); - ( fs.createWriteStream as jest.Mock ).mockReturnValue( mockWriteStream ); - ( path.join as jest.Mock ).mockImplementation( ( ...args ) => args.join( '/' ) ); + ( fs.createWriteStream as unknown as jest.Mock ).mockReturnValue( mockWriteStream ); + ( fsPromises.readdir as unknown as jest.Mock ).mockResolvedValue( mockDirectoryContents ); + ( path.join as unknown as jest.Mock ).mockImplementation( ( ...args ) => args.join( '/' ) ); + ( fs.lstatSync as unknown as jest.Mock ).mockReturnValue( { + isFile: () => true, + } ); } ); describe( 'createArchive', () => { @@ -50,19 +62,38 @@ describe( 'Archive Module', () => { expect( fs.createWriteStream ).toHaveBeenCalledWith( mockArchivePath ); expect( archiver ).toHaveBeenCalledWith( 'zip', { zlib: { level: 9 } } ); expect( mockArchiver.pipe ).toHaveBeenCalledWith( mockWriteStream ); + expect( fsPromises.readdir ).toHaveBeenCalledWith( mockWpContentPath, { + recursive: true, + } ); expect( path.join ).toHaveBeenCalledWith( mockSiteFolder, 'wp-content' ); - expect( mockArchiver.directory ).toHaveBeenCalledWith( - mockWpContentPath, - 'wp-content', - expect.any( Function ) - ); - expect( path.join ).toHaveBeenCalledWith( mockSiteFolder, 'wp-config.php' ); - expect( fs.existsSync ).toHaveBeenCalledWith( mockWpConfigPath ); - expect( mockArchiver.file ).not.toHaveBeenCalled(); + expect( mockArchiver.file ).toHaveBeenCalled(); expect( mockArchiver.finalize ).toHaveBeenCalled(); expect( result ).toBe( mockArchiver ); } ); + it( 'should handle symbolic links correctly', async () => { + ( fs.realpathSync as unknown as jest.Mock ).mockReturnValue( mockSymLinkedFilePath ); + ( fs.lstatSync as unknown as jest.Mock ).mockReturnValue( { + isFile: () => false, + isSymbolicLink: () => true, + } ); + + mockWriteStream.on.mockImplementation( ( event, callback ) => { + if ( event === 'close' ) { + setTimeout( () => callback(), 0 ); + } + return mockWriteStream; + } ); + + mockArchiver.on.mockImplementation( () => mockArchiver ); + + await createArchive( mockSiteFolder, mockArchivePath ); + + expect( fs.realpathSync ).toHaveBeenCalled(); + expect( fs.lstatSync ).toHaveBeenCalled(); + expect( mockArchiver.file ).toHaveBeenCalled(); + } ); + it( 'should include wp-config.php if it exists', async () => { ( fs.existsSync as jest.Mock ).mockReturnValue( true );