From 797a39bfb65e0147ff01ef9d6fa9fd27ff8411df Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Thu, 22 May 2025 18:34:01 +0200 Subject: [PATCH 01/12] Studio: Follow symlinks when exporting site --- cli/commands/preview/test.ts | 40 ++++++++++++++++++++++++++++++++++++ cli/index.ts | 2 ++ cli/lib/archive.ts | 23 +++++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 cli/commands/preview/test.ts diff --git a/cli/commands/preview/test.ts b/cli/commands/preview/test.ts new file mode 100644 index 0000000000..e284674c87 --- /dev/null +++ b/cli/commands/preview/test.ts @@ -0,0 +1,40 @@ +import fs from 'fs'; +import { __ } from '@wordpress/i18n'; +import { PreviewCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { createArchive } from 'cli/lib/archive'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand( siteFolder: string ): Promise< void > { + const archivePath = 'test.zip'; + const logger = new Logger< LoggerAction >(); + + try { + logger.reportStart( LoggerAction.VALIDATE, __( 'Zipping…' ) ); + await createArchive( siteFolder, archivePath ); + console.log( 'hola???', archivePath ); + logger.reportKeyValuePair( 'archive', archivePath ); + logger.reportSuccess( __( 'Zipped' ) ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to create zip file' ), error ); + logger.reportError( loggerError ); + } + } finally { + if ( fs.existsSync( archivePath ) ) { + console.log( 'zip created to ', archivePath ); + } + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'test', + describe: __( 'Test a zip file' ), + handler: async ( argv ) => { + await runCommand( argv.path ); + }, + } ); +}; diff --git a/cli/index.ts b/cli/index.ts index bff330fff9..e627a97b83 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -5,6 +5,7 @@ import yargs from 'yargs'; import { registerCommand as registerCreateCommand } from 'cli/commands/preview/create'; import { registerCommand as registerDeleteCommand } from 'cli/commands/preview/delete'; import { registerCommand as registerListCommand } from 'cli/commands/preview/list'; +import { registerCommand as registerTestCommand } from 'cli/commands/preview/test'; import { registerCommand as registerUpdateCommand } from 'cli/commands/preview/update'; import { loadTranslations } from 'cli/lib/i18n'; import { bumpAggregatedUniqueStat } from 'cli/lib/stats'; @@ -44,6 +45,7 @@ async function main() { registerListCommand( previewYargs ); registerDeleteCommand( previewYargs ); registerUpdateCommand( previewYargs ); + registerTestCommand( previewYargs ); previewYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); } ) .demandCommand( 1, __( 'You must provide a valid command' ) ) diff --git a/cli/lib/archive.ts b/cli/lib/archive.ts index 31089ec91a..c930eb62d9 100644 --- a/cli/lib/archive.ts +++ b/cli/lib/archive.ts @@ -55,3 +55,26 @@ export async function cleanup( archivePath: string ): Promise< void > { }, 0 ); } ); } + +async function getSymlinks( dir: string ): Promise< string[] > { + const files = await fs.promises.readdir( dir ); + const results = await Promise.all( + files.map( async ( file ) => { + const filePath = path.join( dir, file ); + const stats = await fs.promises.lstat( filePath ); + + if ( stats.isSymbolicLink() ) { + return [ filePath ]; + } + + if ( stats.isDirectory() ) { + return await getSymlinks( filePath ); + } + if ( stats.isFile() ) { + return []; + } + return []; + } ) + ); + return results.flat(); +} From 00228b5c7932cfaad9f39ca9d7c2982cfe8d3db7 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Fri, 23 May 2025 09:53:44 +0200 Subject: [PATCH 02/12] Add symbolic link directories as well as files --- cli/commands/preview/test.ts | 2 +- cli/lib/archive.ts | 37 +++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/cli/commands/preview/test.ts b/cli/commands/preview/test.ts index e284674c87..765214cc65 100644 --- a/cli/commands/preview/test.ts +++ b/cli/commands/preview/test.ts @@ -12,7 +12,7 @@ export async function runCommand( siteFolder: string ): Promise< void > { try { logger.reportStart( LoggerAction.VALIDATE, __( 'Zipping…' ) ); await createArchive( siteFolder, archivePath ); - console.log( 'hola???', archivePath ); + logger.reportKeyValuePair( 'archive', archivePath ); logger.reportSuccess( __( 'Zipped' ) ); } catch ( error ) { diff --git a/cli/lib/archive.ts b/cli/lib/archive.ts index c930eb62d9..6090474b03 100644 --- a/cli/lib/archive.ts +++ b/cli/lib/archive.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import fsPromises from 'fs/promises'; import path from 'path'; import { __ } from '@wordpress/i18n'; import archiver, { EntryData } from 'archiver'; @@ -10,6 +11,9 @@ export async function createArchive( siteFolder: string, archivePath: string ): Promise< archiver.Archiver > { + const wpContentFolder = path.join( siteFolder, 'wp-content' ); + const symlinks = await getSymlinks( wpContentFolder ); + return new Promise( ( resolve, reject ) => { const output = fs.createWriteStream( archivePath ); const archive = archiver( 'zip', { @@ -28,13 +32,32 @@ export async function createArchive( path.join( siteFolder, 'wp-content' ), 'wp-content', ( entry: EntryData ) => { - if ( entry.name.includes( '.git' ) || entry.name.includes( 'node_modules' ) ) { + if ( shouldExcludeEntry( entry.name ) ) { + return false; + } + if ( entry.stats?.isSymbolicLink() ) { return false; } + return entry; } ); + for ( const symlink of symlinks ) { + const { symbolicPath, realPath } = symlink; + const archivePath = path.relative( siteFolder, symbolicPath ); + if ( symlink.isDirectory ) { + archive.directory( realPath, archivePath, ( entry: EntryData ) => { + if ( shouldExcludeEntry( entry.name ) ) { + return false; + } + return entry; + } ); + } else { + archive.file( realPath, { name: archivePath } ); + } + } + const wpConfigPath = path.join( siteFolder, 'wp-config.php' ); if ( fs.existsSync( wpConfigPath ) ) { archive.file( wpConfigPath, { name: 'wp-config.php' } ); @@ -56,7 +79,9 @@ export async function cleanup( archivePath: string ): Promise< void > { } ); } -async function getSymlinks( dir: string ): Promise< string[] > { +async function getSymlinks( + dir: string +): Promise< { isDirectory: boolean; symbolicPath: string; realPath: string }[] > { const files = await fs.promises.readdir( dir ); const results = await Promise.all( files.map( async ( file ) => { @@ -64,7 +89,9 @@ async function getSymlinks( dir: string ): Promise< string[] > { const stats = await fs.promises.lstat( filePath ); if ( stats.isSymbolicLink() ) { - return [ filePath ]; + const realPath = await fsPromises.realpath( filePath ); + const realPathStats = await fsPromises.stat( realPath ); + return [ { isDirectory: realPathStats.isDirectory(), symbolicPath: filePath, realPath } ]; } if ( stats.isDirectory() ) { @@ -78,3 +105,7 @@ async function getSymlinks( dir: string ): Promise< string[] > { ); return results.flat(); } + +function shouldExcludeEntry( entryName: string ): boolean { + return entryName.includes( '.git' ) || entryName.includes( 'node_modules' ); +} From 591e7da9f0bb7db8e0496a76ad3e7729404fad8c Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Fri, 23 May 2025 10:34:13 +0200 Subject: [PATCH 03/12] Remove test files --- cli/commands/preview/test.ts | 40 ------------------------------------ cli/index.ts | 2 -- 2 files changed, 42 deletions(-) delete mode 100644 cli/commands/preview/test.ts diff --git a/cli/commands/preview/test.ts b/cli/commands/preview/test.ts deleted file mode 100644 index 765214cc65..0000000000 --- a/cli/commands/preview/test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import fs from 'fs'; -import { __ } from '@wordpress/i18n'; -import { PreviewCommandLoggerAction as LoggerAction } from 'common/logger-actions'; -import { createArchive } from 'cli/lib/archive'; -import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; - -export async function runCommand( siteFolder: string ): Promise< void > { - const archivePath = 'test.zip'; - const logger = new Logger< LoggerAction >(); - - try { - logger.reportStart( LoggerAction.VALIDATE, __( 'Zipping…' ) ); - await createArchive( siteFolder, archivePath ); - - logger.reportKeyValuePair( 'archive', archivePath ); - logger.reportSuccess( __( 'Zipped' ) ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to create zip file' ), error ); - logger.reportError( loggerError ); - } - } finally { - if ( fs.existsSync( archivePath ) ) { - console.log( 'zip created to ', archivePath ); - } - } -} - -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'test', - describe: __( 'Test a zip file' ), - handler: async ( argv ) => { - await runCommand( argv.path ); - }, - } ); -}; diff --git a/cli/index.ts b/cli/index.ts index e627a97b83..bff330fff9 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -5,7 +5,6 @@ import yargs from 'yargs'; import { registerCommand as registerCreateCommand } from 'cli/commands/preview/create'; import { registerCommand as registerDeleteCommand } from 'cli/commands/preview/delete'; import { registerCommand as registerListCommand } from 'cli/commands/preview/list'; -import { registerCommand as registerTestCommand } from 'cli/commands/preview/test'; import { registerCommand as registerUpdateCommand } from 'cli/commands/preview/update'; import { loadTranslations } from 'cli/lib/i18n'; import { bumpAggregatedUniqueStat } from 'cli/lib/stats'; @@ -45,7 +44,6 @@ async function main() { registerListCommand( previewYargs ); registerDeleteCommand( previewYargs ); registerUpdateCommand( previewYargs ); - registerTestCommand( previewYargs ); previewYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); } ) .demandCommand( 1, __( 'You must provide a valid command' ) ) From 1d98a03a43785c8e04ca9f01e9cc9ca06884aec4 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Fri, 23 May 2025 10:46:23 +0200 Subject: [PATCH 04/12] Remove unnecesary code, add some comments --- cli/lib/archive.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/lib/archive.ts b/cli/lib/archive.ts index 6090474b03..5af81641b0 100644 --- a/cli/lib/archive.ts +++ b/cli/lib/archive.ts @@ -86,6 +86,7 @@ async function getSymlinks( const results = await Promise.all( files.map( async ( file ) => { const filePath = path.join( dir, file ); + // Using lstat to use isSymbolicLink method, see https://nodejs.org/api/fs.html#statsissymboliclink const stats = await fs.promises.lstat( filePath ); if ( stats.isSymbolicLink() ) { @@ -97,9 +98,7 @@ async function getSymlinks( if ( stats.isDirectory() ) { return await getSymlinks( filePath ); } - if ( stats.isFile() ) { - return []; - } + // Regular file return []; } ) ); From 5ac3d81e9f9fdfd363f8b64e4a476a43ac83f610 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Tue, 27 May 2025 11:11:26 +0200 Subject: [PATCH 05/12] Avoid returning symlinks for excluded entries --- cli/lib/archive.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/lib/archive.ts b/cli/lib/archive.ts index 5af81641b0..6140e2c5fb 100644 --- a/cli/lib/archive.ts +++ b/cli/lib/archive.ts @@ -88,6 +88,9 @@ async function getSymlinks( const filePath = path.join( dir, file ); // Using lstat to use isSymbolicLink method, see https://nodejs.org/api/fs.html#statsissymboliclink const stats = await fs.promises.lstat( filePath ); + if ( shouldExcludeEntry( file ) ) { + return []; + } if ( stats.isSymbolicLink() ) { const realPath = await fsPromises.realpath( filePath ); From 354c0b317d6efa07e78c0053cda7014390d1bfd3 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Tue, 27 May 2025 16:56:15 +0200 Subject: [PATCH 06/12] Follow symlinks when creating preview --- cli/lib/archive.ts | 80 +++++++++++++++------------------------------- 1 file changed, 25 insertions(+), 55 deletions(-) diff --git a/cli/lib/archive.ts b/cli/lib/archive.ts index 6140e2c5fb..1fcde822c4 100644 --- a/cli/lib/archive.ts +++ b/cli/lib/archive.ts @@ -12,7 +12,10 @@ export async function createArchive( archivePath: string ): Promise< archiver.Archiver > { const wpContentFolder = path.join( siteFolder, 'wp-content' ); - const symlinks = await getSymlinks( wpContentFolder ); + const directoryContents = await fsPromises.readdir( wpContentFolder, { + recursive: true, + withFileTypes: true, + } ); return new Promise( ( resolve, reject ) => { const output = fs.createWriteStream( archivePath ); @@ -28,36 +31,32 @@ export async function createArchive( } ); archive.pipe( output ); - archive.directory( - path.join( siteFolder, 'wp-content' ), - 'wp-content', - ( entry: EntryData ) => { - if ( shouldExcludeEntry( entry.name ) ) { - return false; - } - if ( entry.stats?.isSymbolicLink() ) { - return false; - } - return entry; + for ( const dirEnt of directoryContents ) { + const filePath = path.join( dirEnt.parentPath, dirEnt.name ); + if ( shouldExcludeEntry( filePath ) ) { + continue; } - ); - for ( const symlink of symlinks ) { - const { symbolicPath, realPath } = symlink; - const archivePath = path.relative( siteFolder, symbolicPath ); - if ( symlink.isDirectory ) { - archive.directory( realPath, archivePath, ( entry: EntryData ) => { - if ( shouldExcludeEntry( entry.name ) ) { - return false; - } - return entry; - } ); - } else { - archive.file( realPath, { name: archivePath } ); + const archivePath = path.relative( siteFolder, filePath ); + if ( dirEnt.isSymbolicLink() ) { + const realPath = fs.realpathSync( filePath ); + const stat = fs.statSync( realPath ); + const isDirectory = stat.isDirectory(); + if ( isDirectory ) { + archive.directory( realPath, archivePath, ( entry: EntryData ) => { + if ( shouldExcludeEntry( realPath ) ) { + return false; + } + return entry; + } ); + } else { + archive.file( realPath, { name: archivePath } ); + } + } else if ( dirEnt.isFile() ) { + archive.file( filePath, { name: archivePath } ); } } - const wpConfigPath = path.join( siteFolder, 'wp-config.php' ); if ( fs.existsSync( wpConfigPath ) ) { archive.file( wpConfigPath, { name: 'wp-config.php' } ); @@ -79,35 +78,6 @@ export async function cleanup( archivePath: string ): Promise< void > { } ); } -async function getSymlinks( - dir: string -): Promise< { isDirectory: boolean; symbolicPath: string; realPath: string }[] > { - const files = await fs.promises.readdir( dir ); - const results = await Promise.all( - files.map( async ( file ) => { - const filePath = path.join( dir, file ); - // Using lstat to use isSymbolicLink method, see https://nodejs.org/api/fs.html#statsissymboliclink - const stats = await fs.promises.lstat( filePath ); - if ( shouldExcludeEntry( file ) ) { - return []; - } - - if ( stats.isSymbolicLink() ) { - const realPath = await fsPromises.realpath( filePath ); - const realPathStats = await fsPromises.stat( realPath ); - return [ { isDirectory: realPathStats.isDirectory(), symbolicPath: filePath, realPath } ]; - } - - if ( stats.isDirectory() ) { - return await getSymlinks( filePath ); - } - // Regular file - return []; - } ) - ); - return results.flat(); -} - function shouldExcludeEntry( entryName: string ): boolean { return entryName.includes( '.git' ) || entryName.includes( 'node_modules' ); } From 4d43207296d0cc923b2e59ad9bb22287eb7a8866 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Tue, 27 May 2025 17:09:15 +0200 Subject: [PATCH 07/12] Exclude git and node_modules from symlinked directories --- cli/lib/archive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/lib/archive.ts b/cli/lib/archive.ts index 1fcde822c4..8724055343 100644 --- a/cli/lib/archive.ts +++ b/cli/lib/archive.ts @@ -45,7 +45,7 @@ export async function createArchive( const isDirectory = stat.isDirectory(); if ( isDirectory ) { archive.directory( realPath, archivePath, ( entry: EntryData ) => { - if ( shouldExcludeEntry( realPath ) ) { + if ( shouldExcludeEntry( entry.name ) ) { return false; } return entry; From 4a99ab10cc854bf253bf4bbb6382015374c8b64d Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Tue, 27 May 2025 19:21:35 +0200 Subject: [PATCH 08/12] Update tests --- cli/lib/tests/archive.test.ts | 67 +++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/cli/lib/tests/archive.test.ts b/cli/lib/tests/archive.test.ts index bb59ac32d0..4245e3773a 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,26 @@ 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 = [ + { + name: 'wp-content', + parentPath: mockSiteFolder, + isSymbolicLink: jest.fn().mockReturnValue( false ), + isFile: jest.fn().mockReturnValue( false ), + }, + { + name: 'symlink-folder', + parentPath: mockSiteFolder, + isSymbolicLink: jest.fn().mockReturnValue( true ), + isFile: jest.fn().mockReturnValue( false ), + }, + { + name: 'regular-file.txt', + parentPath: mockSiteFolder, + isSymbolicLink: jest.fn().mockReturnValue( false ), + isFile: jest.fn().mockReturnValue( true ), + }, + ]; const mockArchiver = { pipe: jest.fn(), @@ -28,8 +50,13 @@ 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.realpathSync as unknown as jest.Mock ).mockReturnValue( mockWpContentPath ); + ( fs.statSync as unknown as jest.Mock ).mockReturnValue( { + isDirectory: () => true, + } ); } ); describe( 'createArchive', () => { @@ -50,19 +77,39 @@ 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, + withFileTypes: 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.directory ).toHaveBeenCalled(); + expect( mockArchiver.file ).toHaveBeenCalled(); expect( mockArchiver.finalize ).toHaveBeenCalled(); expect( result ).toBe( mockArchiver ); } ); + it( 'should handle symbolic links correctly', async () => { + ( fs.existsSync as jest.Mock ).mockReturnValue( false ); + ( fs.statSync as unknown as jest.Mock ).mockReturnValue( { + isDirectory: () => 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.statSync ).toHaveBeenCalled(); + expect( mockArchiver.directory ).toHaveBeenCalled(); + } ); + it( 'should include wp-config.php if it exists', async () => { ( fs.existsSync as jest.Mock ).mockReturnValue( true ); From a1ea258c8fff0b73618c6df64a6b626a9f3cdc1e Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Wed, 28 May 2025 12:03:32 +0200 Subject: [PATCH 09/12] Follow symlinks directories from readdir to simplify logic --- cli/lib/archive.ts | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/cli/lib/archive.ts b/cli/lib/archive.ts index 8724055343..3e81911094 100644 --- a/cli/lib/archive.ts +++ b/cli/lib/archive.ts @@ -2,7 +2,7 @@ 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; @@ -14,7 +14,6 @@ export async function createArchive( const wpContentFolder = path.join( siteFolder, 'wp-content' ); const directoryContents = await fsPromises.readdir( wpContentFolder, { recursive: true, - withFileTypes: true, } ); return new Promise( ( resolve, reject ) => { @@ -32,31 +31,28 @@ export async function createArchive( archive.pipe( output ); - for ( const dirEnt of directoryContents ) { - const filePath = path.join( dirEnt.parentPath, dirEnt.name ); - if ( shouldExcludeEntry( filePath ) ) { + for ( const entryPath of directoryContents ) { + if ( entryPath.includes( '.git' ) || entryPath.includes( 'node_modules' ) ) { continue; } - const archivePath = path.relative( siteFolder, filePath ); - if ( dirEnt.isSymbolicLink() ) { - const realPath = fs.realpathSync( filePath ); - const stat = fs.statSync( realPath ); - const isDirectory = stat.isDirectory(); - if ( isDirectory ) { - archive.directory( realPath, archivePath, ( entry: EntryData ) => { - if ( shouldExcludeEntry( entry.name ) ) { - return false; - } - return entry; - } ); - } else { + const absolutePath = path.join( wpContentFolder, entryPath ); + // This can throw if absolutePath is not found + const stat = fs.lstatSync( absolutePath ); + const archivePath = path.relative( siteFolder, absolutePath ); + + if ( stat.isFile() ) { + archive.file( absolutePath, { name: archivePath } ); + } else if ( stat.isSymbolicLink() ) { + try { + const realPath = fs.realpathSync( absolutePath ); archive.file( realPath, { name: archivePath } ); + } catch ( error ) { + // Ignore errors in the symlinks } - } else if ( dirEnt.isFile() ) { - archive.file( filePath, { name: archivePath } ); } } + const wpConfigPath = path.join( siteFolder, 'wp-config.php' ); if ( fs.existsSync( wpConfigPath ) ) { archive.file( wpConfigPath, { name: 'wp-config.php' } ); @@ -77,7 +73,3 @@ export async function cleanup( archivePath: string ): Promise< void > { }, 0 ); } ); } - -function shouldExcludeEntry( entryName: string ): boolean { - return entryName.includes( '.git' ) || entryName.includes( 'node_modules' ); -} From 84527c3162c9d99726f59cfb4a90499461934383 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Wed, 28 May 2025 12:16:46 +0200 Subject: [PATCH 10/12] Fix tests after latest updates --- cli/lib/archive.ts | 1 - cli/lib/tests/archive.test.ts | 40 +++++++++++------------------------ 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/cli/lib/archive.ts b/cli/lib/archive.ts index 3e81911094..cf4c6a226f 100644 --- a/cli/lib/archive.ts +++ b/cli/lib/archive.ts @@ -37,7 +37,6 @@ export async function createArchive( } const absolutePath = path.join( wpContentFolder, entryPath ); - // This can throw if absolutePath is not found const stat = fs.lstatSync( absolutePath ); const archivePath = path.relative( siteFolder, absolutePath ); diff --git a/cli/lib/tests/archive.test.ts b/cli/lib/tests/archive.test.ts index 4245e3773a..0fc98842ba 100644 --- a/cli/lib/tests/archive.test.ts +++ b/cli/lib/tests/archive.test.ts @@ -15,25 +15,11 @@ describe( 'Archive Module', () => { const mockWpContentPath = '/mock/site/folder/wp-content'; const mockWpConfigPath = '/mock/site/folder/wp-config.php'; const mockDirectoryContents = [ - { - name: 'wp-content', - parentPath: mockSiteFolder, - isSymbolicLink: jest.fn().mockReturnValue( false ), - isFile: jest.fn().mockReturnValue( false ), - }, - { - name: 'symlink-folder', - parentPath: mockSiteFolder, - isSymbolicLink: jest.fn().mockReturnValue( true ), - isFile: jest.fn().mockReturnValue( false ), - }, - { - name: 'regular-file.txt', - parentPath: mockSiteFolder, - isSymbolicLink: jest.fn().mockReturnValue( false ), - isFile: jest.fn().mockReturnValue( true ), - }, + '/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(), @@ -53,9 +39,8 @@ describe( 'Archive Module', () => { ( 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.realpathSync as unknown as jest.Mock ).mockReturnValue( mockWpContentPath ); - ( fs.statSync as unknown as jest.Mock ).mockReturnValue( { - isDirectory: () => true, + ( fs.lstatSync as unknown as jest.Mock ).mockReturnValue( { + isFile: () => true, } ); } ); @@ -79,19 +64,18 @@ describe( 'Archive Module', () => { expect( mockArchiver.pipe ).toHaveBeenCalledWith( mockWriteStream ); expect( fsPromises.readdir ).toHaveBeenCalledWith( mockWpContentPath, { recursive: true, - withFileTypes: true, } ); expect( path.join ).toHaveBeenCalledWith( mockSiteFolder, 'wp-content' ); - expect( mockArchiver.directory ).toHaveBeenCalled(); expect( mockArchiver.file ).toHaveBeenCalled(); expect( mockArchiver.finalize ).toHaveBeenCalled(); expect( result ).toBe( mockArchiver ); } ); it( 'should handle symbolic links correctly', async () => { - ( fs.existsSync as jest.Mock ).mockReturnValue( false ); - ( fs.statSync as unknown as jest.Mock ).mockReturnValue( { - isDirectory: () => true, + ( 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 ) => { @@ -106,8 +90,8 @@ describe( 'Archive Module', () => { await createArchive( mockSiteFolder, mockArchivePath ); expect( fs.realpathSync ).toHaveBeenCalled(); - expect( fs.statSync ).toHaveBeenCalled(); - expect( mockArchiver.directory ).toHaveBeenCalled(); + expect( fs.lstatSync ).toHaveBeenCalled(); + expect( mockArchiver.file ).toHaveBeenCalled(); } ); it( 'should include wp-config.php if it exists', async () => { From 683e81d3d9d90fd172f5951c3fbf370999d6511a Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Wed, 28 May 2025 12:34:20 +0200 Subject: [PATCH 11/12] Extract the get path logic to an external function --- cli/lib/archive.ts | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/cli/lib/archive.ts b/cli/lib/archive.ts index cf4c6a226f..8a9642efa4 100644 --- a/cli/lib/archive.ts +++ b/cli/lib/archive.ts @@ -37,18 +37,10 @@ export async function createArchive( } const absolutePath = path.join( wpContentFolder, entryPath ); - const stat = fs.lstatSync( absolutePath ); + const filePath = getPathIfExists( absolutePath ); const archivePath = path.relative( siteFolder, absolutePath ); - - if ( stat.isFile() ) { - archive.file( absolutePath, { name: archivePath } ); - } else if ( stat.isSymbolicLink() ) { - try { - const realPath = fs.realpathSync( absolutePath ); - archive.file( realPath, { name: archivePath } ); - } catch ( error ) { - // Ignore errors in the symlinks - } + if ( filePath ) { + archive.file( filePath, { name: archivePath } ); } } @@ -72,3 +64,20 @@ export async function cleanup( archivePath: string ): Promise< void > { }, 0 ); } ); } + +function getPathIfExists( 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 the original path + return null; + } + } + return null; +} From d603b339081a8a5a25b7ff0fc76ba1de37ba02c9 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Wed, 28 May 2025 12:39:28 +0200 Subject: [PATCH 12/12] Update comment and rename function for clarity --- cli/lib/archive.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/lib/archive.ts b/cli/lib/archive.ts index 8a9642efa4..1c249af87b 100644 --- a/cli/lib/archive.ts +++ b/cli/lib/archive.ts @@ -37,7 +37,7 @@ export async function createArchive( } const absolutePath = path.join( wpContentFolder, entryPath ); - const filePath = getPathIfExists( absolutePath ); + const filePath = getRealFilePathIfExists( absolutePath ); const archivePath = path.relative( siteFolder, absolutePath ); if ( filePath ) { archive.file( filePath, { name: archivePath } ); @@ -65,7 +65,7 @@ export async function cleanup( archivePath: string ): Promise< void > { } ); } -function getPathIfExists( absolutePath: string ): string | null { +function getRealFilePathIfExists( absolutePath: string ): string | null { const stat = fs.lstatSync( absolutePath ); if ( stat.isFile() ) { return absolutePath; @@ -75,9 +75,10 @@ function getPathIfExists( absolutePath: string ): string | null { try { return fs.realpathSync( absolutePath ); } catch ( error ) { - // If there's an error resolving the symlink, return the original path + // If there's an error resolving the symlink, return null return null; } } + // Ignore directories return null; }