Skip to content
4 changes: 3 additions & 1 deletion apps/studio/e2e/page-objects/add-site-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export default class AddSiteModal {
}

get fileInput() {
return this.page.locator( 'input[type="file"][accept=".json,application/json"]' );
return this.page.locator(
'input[type="file"][accept=".json,.zip,application/json,application/zip"]'
);
}

get backupFileInput() {
Expand Down
59 changes: 59 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from '@studio/common/lib/agent-skills';
import { validateBlueprintData } from '@studio/common/lib/blueprint-validation';
import { parseCliError, errorMessageContains } from '@studio/common/lib/cli-error';
import { extractZip } from '@studio/common/lib/extract-zip';
import {
calculateDirectorySizeForArchive,
isWordPressDirectory,
Expand Down Expand Up @@ -693,6 +694,11 @@ export async function createSite(
} );

throw error;
} finally {
if ( blueprint?.filePath ) {
const blueprintDir = nodePath.dirname( nodePath.resolve( blueprint.filePath ) );
await removeBlueprintTempDir( blueprintDir ).catch( () => {} );
}
}
}

Expand Down Expand Up @@ -1923,6 +1929,59 @@ export async function readBlueprintFile(
return JSON.parse( fileContents );
}

export async function extractBlueprintBundle(
_event: IpcMainInvokeEvent,
zipFilePath: string
): Promise< {
blueprintJson: Blueprint[ 'blueprint' ];
blueprintJsonPath: string;
tempDir: string;
} > {
const resolvedZipPath = nodePath.resolve( zipFilePath );
const tempDir = await fsPromises.mkdtemp(
nodePath.join( os.tmpdir(), 'studio-blueprint-bundle-' )
);

try {
await extractZip( resolvedZipPath, tempDir );

const blueprintJsonPath = nodePath.join( tempDir, 'blueprint.json' );
try {
await fsPromises.access( blueprintJsonPath );
} catch {
throw new Error(
__(
'No blueprint.json found in the ZIP file. Please ensure the ZIP contains a blueprint.json at its root.'
)
);
}

const fileContents = await fsPromises.readFile( blueprintJsonPath, 'utf-8' );
const blueprintJson = JSON.parse( fileContents );

return { blueprintJson, blueprintJsonPath, tempDir };
} catch ( error ) {
await fsPromises.rm( tempDir, { recursive: true, force: true } );
throw error;
}
}

async function removeBlueprintTempDir( tempDir: string ): Promise< void > {
const allowedPrefix = nodePath.join( os.tmpdir(), 'studio-blueprint-bundle-' );
const resolvedDir = nodePath.resolve( tempDir );
if ( ! resolvedDir.startsWith( allowedPrefix ) ) {
throw new Error( 'Invalid temp directory path' );
}
await fsPromises.rm( resolvedDir, { recursive: true, force: true } );
}

export async function cleanupBlueprintTempDir(
_event: IpcMainInvokeEvent,
tempDir: string
): Promise< void > {
await removeBlueprintTempDir( tempDir );
}

export async function setWindowControlVisibility( event: IpcMainInvokeEvent, visible: boolean ) {
const parentWindow = BrowserWindow.fromWebContents( event.sender );
if ( ! parentWindow ) {
Expand Down
128 changes: 94 additions & 34 deletions apps/studio/src/modules/add-site/components/blueprints.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generateDefaultBlueprintDescription } from '@studio/common/lib/blueprint-settings';
import { BlueprintValidationWarning } from '@studio/common/lib/blueprint-validation';
import {
__experimentalVStack as VStack,
__experimentalHStack as HStack,
Expand Down Expand Up @@ -49,7 +50,7 @@ interface AddSiteBlueprintProps {
isLoading: boolean;
selectedBlueprint: string | null;
onBlueprintChange: ( blueprintId: string ) => void;
onFileBlueprintSelect?: ( blueprint: Blueprint ) => void;
onFileBlueprintSelect?: ( blueprint: Blueprint, warnings?: BlueprintValidationWarning[] ) => void;
}

export function AddSiteBlueprintSelector( {
Expand Down Expand Up @@ -183,50 +184,79 @@ export function AddSiteBlueprintSelector( {
[ handleBlueprintClick, handlePreviewClick, __ ]
);

const validateAndBuildBlueprint = async (
blueprintJson: Blueprint[ 'blueprint' ],
fileName: string,
filePath: string,
onCleanup?: () => void
): Promise< {
blueprint: Blueprint;
warnings: BlueprintValidationWarning[] | undefined;
} | null > => {
const meta = blueprintJson as {
version?: number;
meta?: { title?: string; description?: string };
};

if ( meta.version === 2 ) {
setValidationError(
__( 'Blueprint v2 format is not supported yet. Please use Blueprint v1 format.' )
);
onCleanup?.();
return null;
}

const validation = await getIpcApi().validateBlueprint( blueprintJson );
if ( ! validation.valid ) {
setValidationError( validation.error || __( 'Invalid Blueprint format' ) );
onCleanup?.();
return null;
}

const warnings =
validation.warnings && validation.warnings.length > 0 ? validation.warnings : undefined;

const baseName = fileName.replace( /\.(json|zip)$/, '' );
const blueprint: Blueprint = {
slug: `file:${ fileName }`,
title: meta.meta?.title || baseName,
excerpt:
meta.meta?.description ||
generateDefaultBlueprintDescription(
blueprintJson as Parameters< typeof generateDefaultBlueprintDescription >[ 0 ]
),
image: '',
playground_url: '',
blueprint: blueprintJson,
filePath,
};

return { blueprint, warnings };
};

const handleFileSelect = async ( event: React.ChangeEvent< HTMLInputElement > ) => {
const file = event.target.files?.[ 0 ];
setValidationError( undefined );
setUploadedFileName( null );

if ( file && file.type === 'application/json' && onFileBlueprintSelect ) {
const isJson = file?.type === 'application/json' || file?.name.endsWith( '.json' );
const isZip = file?.type === 'application/zip' || file?.name.endsWith( '.zip' );

if ( file && isJson && onFileBlueprintSelect ) {
setUploadedFileName( file.name );

try {
const text = await file.text();
const blueprintJson = JSON.parse( text );

if ( blueprintJson.version === 2 ) {
setValidationError(
__( 'Blueprint v2 format is not supported yet. Please use Blueprint v1 format.' )
);
if ( fileRef.current ) {
fileRef.current.value = '';
}
return;
const result = await validateAndBuildBlueprint(
blueprintJson,
file.name,
getIpcApi().getPathForFile( file )
);
if ( result ) {
onFileBlueprintSelect( result.blueprint, result.warnings );
}

const validation = await getIpcApi().validateBlueprint( blueprintJson );
if ( ! validation.valid ) {
setValidationError( validation.error || __( 'Invalid Blueprint format' ) );
if ( fileRef.current ) {
fileRef.current.value = '';
}
return;
}

const fileBlueprint: Blueprint = {
slug: `file:${ file.name }`, // Use filename as part of the slug
title: blueprintJson.meta?.title || file.name.replace( '.json', '' ),
excerpt:
blueprintJson.meta?.description || generateDefaultBlueprintDescription( blueprintJson ),
image: '', // No image for file-based blueprints
playground_url: '', // No playground URL for file-based blueprints
blueprint: blueprintJson, // The actual blueprint JSON
filePath: getIpcApi().getPathForFile( file ),
};

setUploadedFileName( null );
onFileBlueprintSelect( fileBlueprint );
} catch ( error ) {
if ( error instanceof SyntaxError ) {
setValidationError(
Expand All @@ -241,6 +271,36 @@ export function AddSiteBlueprintSelector( {
}
console.error( 'Failed to parse Blueprint file:', error );
}
} else if ( file && isZip && onFileBlueprintSelect ) {
setUploadedFileName( file.name );
let tempDir: string | undefined;

try {
const zipPath = getIpcApi().getPathForFile( file );
const extracted = await getIpcApi().extractBlueprintBundle( zipPath );
tempDir = extracted.tempDir;
const result = await validateAndBuildBlueprint(
extracted.blueprintJson,
file.name,
extracted.blueprintJsonPath,
() => void getIpcApi().cleanupBlueprintTempDir( extracted.tempDir )
);
if ( result ) {
onFileBlueprintSelect( result.blueprint, result.warnings );
}
setUploadedFileName( null );
} catch ( error ) {
if ( tempDir ) {
void getIpcApi().cleanupBlueprintTempDir( tempDir );
}
if ( error instanceof SyntaxError ) {
setValidationError(
sprintf( __( 'Invalid JSON format in blueprint.json: %s' ), error.message )
);
} else {
setValidationError( __( 'Failed to load Blueprint ZIP file. Please try again.' ) );
}
}
}
if ( fileRef.current ) {
fileRef.current.value = '';
Expand Down Expand Up @@ -326,7 +386,7 @@ export function AddSiteBlueprintSelector( {
<input
ref={ fileRef }
type="file"
accept=".json,application/json"
accept=".json,.zip,application/json,application/zip"
onChange={ handleFileSelect }
className="hidden"
/>
Expand Down
7 changes: 5 additions & 2 deletions apps/studio/src/modules/add-site/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
MINIMUM_WORDPRESS_VERSION,
} from '@studio/common/constants';
import { extractFormValuesFromBlueprint } from '@studio/common/lib/blueprint-settings';
import { BlueprintPreferredVersions } from '@studio/common/lib/blueprint-validation';
import {
BlueprintPreferredVersions,
BlueprintValidationWarning,
} from '@studio/common/lib/blueprint-validation';
import { SupportedPHPVersion, SupportedPHPVersionsList } from '@studio/common/types/php-versions';
import { SyncSite } from '@studio/common/types/sync';
import { speak } from '@wordpress/a11y';
Expand Down Expand Up @@ -279,7 +282,7 @@ function NavigationContent( props: NavigationContentProps ) {
);

const handleFileBlueprintSelect = useCallback(
( blueprint: Blueprint ) => {
( blueprint: Blueprint, _warnings?: BlueprintValidationWarning[] ) => {
handleBlueprintFormValues( blueprint );
goTo( '/blueprint/select/details' );
},
Expand Down
3 changes: 3 additions & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ const api: IpcApi = {
ipcRenderer.invoke( 'listLocalFileTree', siteId, path, maxDepth ),
validateBlueprint: ( blueprintJson ) => ipcRendererInvoke( 'validateBlueprint', blueprintJson ),
readBlueprintFile: ( filePath ) => ipcRendererInvoke( 'readBlueprintFile', filePath ),
extractBlueprintBundle: ( zipFilePath ) =>
ipcRendererInvoke( 'extractBlueprintBundle', zipFilePath ),
cleanupBlueprintTempDir: ( tempDir ) => ipcRendererInvoke( 'cleanupBlueprintTempDir', tempDir ),
showSiteContextMenu: ( context ) => ipcRendererSend( 'showSiteContextMenu', context ),
setWindowControlVisibility: ( visible ) =>
ipcRendererInvoke( 'setWindowControlVisibility', visible ),
Expand Down
5 changes: 5 additions & 0 deletions tools/common/lib/blueprint-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ export type BlueprintPreferredVersions = {
wp?: string;
};

export type BlueprintValidationWarning = {
message: string;
};

type BlueprintValidationError = {
valid: false;
error: string;
};
type BlueprintValidationSuccess = {
valid: true;
warnings?: BlueprintValidationWarning[];
};
export type BlueprintValidationResult = BlueprintValidationError | BlueprintValidationSuccess;

Expand Down
Loading