diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index be9dc6e86d..6b8ae3b809 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -4,6 +4,7 @@ import { createInterface } from 'readline'; import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; import { generateBackupFilename } from '@studio/common/lib/generate-backup-filename'; import { ImportEvents } from '@studio/common/lib/import-export-events'; +import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { serializePlugins } from '@studio/common/lib/serialize-plugins'; import { type SupportedPHPVersion } from '@studio/common/types/php-versions'; import { __, sprintf } from '@wordpress/i18n'; @@ -28,6 +29,34 @@ export interface Importer extends ImportExportEventEmitter { import( site: SiteData ): Promise< ImporterResult >; } +// Recovers from EEXIST/ENOTDIR by removing a non-directory blocker on the path. +export async function ensureDir( dir: string ): Promise< void > { + try { + await fs.promises.mkdir( dir, { recursive: true } ); + } catch ( error ) { + if ( ! isErrnoException( error ) || ( error.code !== 'EEXIST' && error.code !== 'ENOTDIR' ) ) { + throw error; + } + const parent = path.dirname( dir ); + if ( parent === dir ) { + throw error; + } + await ensureDir( parent ); + try { + const stat = await fs.promises.stat( dir ); + if ( ! stat.isDirectory() ) { + await trash( dir ); + console.warn( `ensureDir: moved non-directory blocker at ${ dir } to trash` ); + } + } catch ( e ) { + if ( ! isErrnoException( e ) || e.code !== 'ENOENT' ) { + throw e; + } + } + await fs.promises.mkdir( dir, { recursive: true } ); + } +} + abstract class BaseImporter extends ImportExportEventEmitter implements Importer { protected meta?: MetaFileData; @@ -236,7 +265,7 @@ abstract class BaseBackupImporter extends BaseImporter { ); const destPath = path.join( wpContentDestDir, relativePath ); - await fs.promises.mkdir( path.dirname( destPath ), { recursive: true } ); + await ensureDir( path.dirname( destPath ) ); await fs.promises.copyFile( file, destPath ); processedItems++; diff --git a/apps/cli/lib/import-export/import/importers/tests/importer.test.ts b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts new file mode 100644 index 0000000000..816e8c0571 --- /dev/null +++ b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts @@ -0,0 +1,55 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ensureDir } from '../importer'; + +describe( 'ensureDir', () => { + let tmpDir: string; + + beforeEach( () => { + tmpDir = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-ensure-dir-' ) ); + } ); + + afterEach( () => { + fs.rmSync( tmpDir, { recursive: true, force: true } ); + } ); + + it( 'creates a directory that does not exist', async () => { + const target = path.join( tmpDir, 'a', 'b', 'c' ); + await ensureDir( target ); + expect( fs.lstatSync( target ).isDirectory() ).toBe( true ); + } ); + + it( 'is a no-op when the directory already exists', async () => { + const target = path.join( tmpDir, 'existing' ); + fs.mkdirSync( target ); + await expect( ensureDir( target ) ).resolves.toBeUndefined(); + expect( fs.lstatSync( target ).isDirectory() ).toBe( true ); + } ); + + it( 'replaces a non-directory file blocking the target path', async () => { + const plugins = path.join( tmpDir, 'wp-content', 'plugins' ); + fs.mkdirSync( plugins, { recursive: true } ); + const blocker = path.join( plugins, 'akismet' ); + fs.writeFileSync( blocker, '/managed/akismet' ); + expect( fs.lstatSync( blocker ).isFile() ).toBe( true ); + + await ensureDir( blocker ); + + expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true ); + } ); + + it( 'replaces a non-directory file blocking an ancestor of the target path', async () => { + const plugins = path.join( tmpDir, 'wp-content', 'plugins' ); + fs.mkdirSync( plugins, { recursive: true } ); + const blocker = path.join( plugins, 'akismet' ); + fs.writeFileSync( blocker, '/managed/akismet' ); + + const deeper = path.join( blocker, '_inc' ); + await ensureDir( deeper ); + + expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true ); + expect( fs.lstatSync( deeper ).isDirectory() ).toBe( true ); + } ); +} );