Skip to content
Closed
47 changes: 37 additions & 10 deletions cli/lib/archive.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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', {
Expand All @@ -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 ) ) {
Expand All @@ -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;
}
51 changes: 41 additions & 10 deletions cli/lib/tests/archive.test.ts
Original file line number Diff line number Diff line change
@@ -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' );

Expand All @@ -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(),
Expand All @@ -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', () => {
Expand All @@ -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 );

Expand Down
Loading