Skip to content

Conversation

@bcotrim
Copy link
Contributor

@bcotrim bcotrim commented Mar 25, 2025

Related issues

Proposed Changes

  • Implemented go command to create a preview site in WordPress.com
  • Added unit tests for the created workflow

Note: As discussed on Slack, the way we fetch the user auth token is hard coded and will be adjusted in the a follow-up PR.

Testing Instructions

In the project root folder:

  • npm run cli:build
  • node dist/cli/main.js go [folder]

Some suggested test scenarios:

  1. Folder is not a WordPress installation folder:

image

  1. Folder is a WordPress installation but you don't have WordPress.com authentication in Studio app:

image

  1. Folder is a WordPress installation

image

  1. Empty folder argument, navigating to a WordPress installation folder and running the cli dist file, should try to create a preview site for that folder

image

  1. Running the command when you reach the 10 preview sites limit

image

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?

@bcotrim bcotrim changed the title start implementing go command to create preview sites Implement studio go for creating preview sites Mar 25, 2025
Copy link
Contributor

@fredrikekelund fredrikekelund left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have yet to test this, but the code is looking good overall and the structure is easy to follow 👍

My biggest comment is that I'd suggest following a "classical" error handling pattern where exceptions are thrown instead of returned. Implementing a custom error class that extends Error would make this easier because we could pass a logger action as a prop on the error.

Comment on lines 17 to 71
async function runCommand( siteFolder: string, outputFormat?: OutputFormat ): Promise< void > {
const archivePath = path.join(
os.tmpdir(),
`${ path.basename( siteFolder ) }-${ Date.now() }.zip`
);
const logger = new Logger< LoggerAction >( outputFormat );

logger.reportStart( LoggerAction.VALIDATE, 'Validating...' );
const isValidSiteFolder = validateSiteFolder( siteFolder );
if ( isValidSiteFolder instanceof Error ) {
logger.reportError( LoggerAction.VALIDATE, isValidSiteFolder.message );
return;
}
const token = await getAuthToken();
if ( ! token ) {
logger.reportError(
LoggerAction.VALIDATE,
'Authentication required. Please run the Studio app and authenticate first.'
);
return;
}
logger.reportSuccess( LoggerAction.VALIDATE, 'Validation successful' );

logger.reportStart( LoggerAction.ARCHIVE, 'Creating archive...' );
const archive = await createArchive( siteFolder, archivePath );
if ( archive instanceof Error ) {
logger.reportError( LoggerAction.ARCHIVE, archive.message );
return;
}
logger.reportSuccess( LoggerAction.ARCHIVE, 'Archive created' );

logger.reportStart( LoggerAction.UPLOAD, 'Uploading archive...' );
const uploadResponse = await uploadArchive( archivePath, token );
if ( uploadResponse instanceof Error ) {
logger.reportError( LoggerAction.UPLOAD, uploadResponse.message );
return;
}
if ( ! uploadResponse.site_url || ! uploadResponse.site_id ) {
logger.reportError( LoggerAction.UPLOAD, 'Failed to upload archive' );
return;
}
logger.reportSuccess( LoggerAction.UPLOAD, 'Archive uploaded' );

logger.reportStart( LoggerAction.READY, 'Creating preview site...' );
const isSiteReady = await waitForSiteReady( uploadResponse.site_id, token );
if ( ! isSiteReady ) {
logger.reportError( LoggerAction.READY, 'Failed to create preview site' );
return;
}
cleanup( archivePath );
logger.reportSuccess(
LoggerAction.READY,
`Preview site available at: https://${ uploadResponse.site_url }`
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async function runCommand( siteFolder: string, outputFormat?: OutputFormat ): Promise< void > {
const archivePath = path.join(
os.tmpdir(),
`${ path.basename( siteFolder ) }-${ Date.now() }.zip`
);
const logger = new Logger< LoggerAction >( outputFormat );
logger.reportStart( LoggerAction.VALIDATE, 'Validating...' );
const isValidSiteFolder = validateSiteFolder( siteFolder );
if ( isValidSiteFolder instanceof Error ) {
logger.reportError( LoggerAction.VALIDATE, isValidSiteFolder.message );
return;
}
const token = await getAuthToken();
if ( ! token ) {
logger.reportError(
LoggerAction.VALIDATE,
'Authentication required. Please run the Studio app and authenticate first.'
);
return;
}
logger.reportSuccess( LoggerAction.VALIDATE, 'Validation successful' );
logger.reportStart( LoggerAction.ARCHIVE, 'Creating archive...' );
const archive = await createArchive( siteFolder, archivePath );
if ( archive instanceof Error ) {
logger.reportError( LoggerAction.ARCHIVE, archive.message );
return;
}
logger.reportSuccess( LoggerAction.ARCHIVE, 'Archive created' );
logger.reportStart( LoggerAction.UPLOAD, 'Uploading archive...' );
const uploadResponse = await uploadArchive( archivePath, token );
if ( uploadResponse instanceof Error ) {
logger.reportError( LoggerAction.UPLOAD, uploadResponse.message );
return;
}
if ( ! uploadResponse.site_url || ! uploadResponse.site_id ) {
logger.reportError( LoggerAction.UPLOAD, 'Failed to upload archive' );
return;
}
logger.reportSuccess( LoggerAction.UPLOAD, 'Archive uploaded' );
logger.reportStart( LoggerAction.READY, 'Creating preview site...' );
const isSiteReady = await waitForSiteReady( uploadResponse.site_id, token );
if ( ! isSiteReady ) {
logger.reportError( LoggerAction.READY, 'Failed to create preview site' );
return;
}
cleanup( archivePath );
logger.reportSuccess(
LoggerAction.READY,
`Preview site available at: https://${ uploadResponse.site_url }`
);
}
class CommandError extends Error {
action: LoggerAction;
constructor( action: LoggerAction, message: string ) {
super( message );
this.action = action;
}
}
async function runCommand( siteFolder: string, outputFormat?: OutputFormat ): Promise< void > {
const archivePath = path.join(
os.tmpdir(),
`${ path.basename( siteFolder ) }-${ Date.now() }.zip`
);
const logger = new Logger< LoggerAction >( outputFormat );
try {
logger.reportStart( LoggerAction.VALIDATE, 'Validating...' );
validateSiteFolder( siteFolder );
const token = await getAuthToken();
logger.reportSuccess( LoggerAction.VALIDATE, 'Validation successful' );
logger.reportStart( LoggerAction.ARCHIVE, 'Creating archive...' );
await createArchive( siteFolder, archivePath );
logger.reportSuccess( LoggerAction.ARCHIVE, 'Archive created' );
logger.reportStart( LoggerAction.UPLOAD, 'Uploading archive...' );
const uploadResponse = await uploadArchive( archivePath, token );
if ( ! uploadResponse.site_url || ! uploadResponse.site_id ) {
throw new CommandError( LoggerAction.UPLOAD, 'Failed to upload archive' );
}
logger.reportSuccess( LoggerAction.UPLOAD, 'Archive uploaded' );
logger.reportStart( LoggerAction.READY, 'Creating preview site...' );
const isSiteReady = await waitForSiteReady( uploadResponse.site_id, token );
if ( ! isSiteReady ) {
throw new CommandError( LoggerAction.READY, 'Failed to create preview site' );
}
cleanup( archivePath );
logger.reportSuccess(
LoggerAction.READY,
`Preview site available at: https://${ uploadResponse.site_url }`
);
} catch ( error ) {
if ( error instanceof CommandError ) {
logger.reportError( error.action, error.message );
}
}
}

This is probably subjective, but given this async / await ES6+ world we live in, I would love to embrace classical JS error handling with exceptions that actually throw instead of potentially returning errors.

Implementing custom error classes (like CommandError in my suggestion) and using them across the different CLI modules would help make this more convenient.

Comment on lines 4 to 10
function isWordPressDirectory( projectPath: string ): boolean {
return (
fs.existsSync( path.join( projectPath, 'wp-content' ) ) &&
fs.existsSync( path.join( projectPath, 'wp-includes' ) ) &&
fs.existsSync( path.join( projectPath, 'wp-load.php' ) )
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting example of logic that would ideally be shared across Studio and the CLI, but that is small enough to not warrant implementing its own CLI command.

I would actually consider replacing this with:

import { isWordPressDirectory } from 'src/lib/fs-utils';

Webpack/TS can tree shake the code just fine, and we should take advantage of the fact that the CLI code lives right next to the Studio code while we can.

Comment on lines 20 to 21
const userData = JSON.parse( fs.readFileSync( appDataPath, 'utf8' ) );
return userData.authToken?.accessToken || null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a zod schema to validate the authToken.accessToken part of the config file.

Comment on lines +13 to +15
const archive = archiver( 'zip', {
zlib: { level: ZIP_COMPRESSION_LEVEL },
} );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not something we have to look into now, but there was a recent request to follow symlinks when creating preview site archives. This makes a lot of sense to me, and if there's an easy way to solve it with archiver, we might as well solve it now.

Copy link
Contributor

@fredrikekelund fredrikekelund Mar 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For reference, here's the relevant issue STU-309

Comment on lines 42 to 46
const response = await wpcom.req.post< CreateSiteResponse >( {
path: '/jurassic-ninja/create-new-site-from-zip',
apiNamespace: 'wpcom/v2',
formData,
} );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd add a zod schema here.

Comment on lines 62 to 65
const response = await wpcom.req.get< StatusResponse >( '/jurassic-ninja/status', {
apiNamespace: 'wpcom/v2',
site_id: siteId,
} );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd add a zod schema here.

Comment on lines 9 to 20
export interface StatusResponse {
status: SnapshotStatus;
domain_name: string;
atomic_site_id: number;
is_deleted: string;
}

export enum SnapshotStatus {
Pending = '0',
Processing = '1',
Active = '2',
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the enum usage 👍

Copy link
Contributor

@fredrikekelund fredrikekelund left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tested this and it's working great overall 👍 I pushed two small commits to fix a deprecation warning from an outdated descendant dependency and to fix how the --output-format option is parsed.

The only issue I encountered is that if I pass the --output-format=json option, the command exits early with the following error:

/Users/fredrik/Code/studio/dist/cli/main.js:43174
            throw new Error('Cannot report success for an action that is not currently in progress');
                  ^

Error: Cannot report success for an action that is not currently in progress
    at Logger.reportSuccess (/Users/fredrik/Code/studio/dist/cli/main.js:43174:19)
    at /Users/fredrik/Code/studio/dist/cli/main.js:42846:16
    at Generator.next (<anonymous>)
    at fulfilled (/Users/fredrik/Code/studio/dist/cli/main.js:42805:58)
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)

Node.js v22.9.0

"license": "GPL-2.0-or-later",
"main": "index.js"
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you use some tooling that strips out newlines from the end of files, @bcotrim?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing besides Cursor 🤔

@fredrikekelund
Copy link
Contributor

One more thing: we should write the newly created preview site to the Studio appdata file.

@bcotrim bcotrim marked this pull request as ready for review March 27, 2025 20:19
@bcotrim bcotrim force-pushed the add/studio_go_create_preview branch from 3cc1ce4 to 554b065 Compare March 27, 2025 20:20
@bcotrim bcotrim requested a review from fredrikekelund March 27, 2025 21:56
Copy link
Contributor

@fredrikekelund fredrikekelund left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shaping up nicely 👍

I left a number of comments on the code. Functionality-wise, I'm still getting errors when passing the --output-format=json option.

cli/logger.ts Outdated
Comment on lines 68 to 70
if ( this.currentAction !== error.action ) {
throw new Error( 'Cannot report error for an action that is not currently in progress' );
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this? It seems like removing it would greatly reduce the number of times we pass around LoggerAction references.

Comment on lines 43 to 47
const result = UserDataSchema.safeParse( userData );

if ( ! result.success ) {
throw new LoggerError( `Invalid appdata format. Please run the Studio app again.`, action );
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const result = UserDataSchema.safeParse( userData );
if ( ! result.success ) {
throw new LoggerError( `Invalid appdata format. Please run the Studio app again.`, action );
}
const result = UserDataSchema.parse( userData );

Since we already have an extensive catch clause here, it seems like we could use the zod.parse method.

userData.version = 1;
}

// Create a deep copy to avoid reference issues
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you encounter any issues like this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did, but I re-tested and I didn't find the same issue, so I think it's safe to remove now.

const dataToSave = JSON.parse( JSON.stringify( userData ) );
const fileContent = JSON.stringify( dataToSave, null, 2 ) + '\n';

// Write the file
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Write the file

Comment on lines 16 to 22
const UserDataSchema = z.object( {
version: z.number().optional(),
sites: z.array( z.any() ).optional(),
snapshots: z.array( SnapshotSchema ).optional(),
authToken: z.any().optional(),
locale: z.string().optional(),
} );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I applaud this initiative, but this schema is missing a couple of top-level items (sentryUserId and lastSeenVersion, for example).

The best-case scenario is that we have a shared schema for the user-config file between Studio and the CLI. However, to avoid blowing the scope of this PR, I suggest keeping a schema that validates only the snapshots array in the user config. All other properties should be left untouched between reading and writing the file.

Comment on lines 57 to 63
// Validate the response against our schema
const result = CreateSiteResponseSchema.safeParse( rawResponse );

if ( ! result.success ) {
throw new LoggerError( 'Invalid API response', action );
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Validate the response against our schema
const result = CreateSiteResponseSchema.safeParse( rawResponse );
if ( ! result.success ) {
throw new LoggerError( 'Invalid API response', action );
}
const result = CreateSiteResponseSchema.parse( rawResponse );

Again, we have an extensive catch clause already, so I don't see the need for using safeParse

Comment on lines 93 to 98
const result = StatusResponseSchema.safeParse( rawResponse );

if ( ! result.success ) {
return false;
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const result = StatusResponseSchema.safeParse( rawResponse );
if ( ! result.success ) {
return false;
}
const result = StatusResponseSchema.parse( rawResponse );

];

try {
const rawResponse = await wpcom.req.post< CreateSiteResponse >( {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const rawResponse = await wpcom.req.post< CreateSiteResponse >( {
const rawResponse = await wpcom.req.post( {

The default response type is unknown, which is actually accurate. We can't guarantee anything about the data type until it's parsed by zod.

const wpcom = new WPCOM( token );

try {
const rawResponse = await wpcom.req.get< StatusResponse >( '/jurassic-ninja/status', {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const rawResponse = await wpcom.req.get< StatusResponse >( '/jurassic-ninja/status', {
const rawResponse = await wpcom.req.get( '/jurassic-ninja/status', {

Again, no guarantees until we've parsed the data with zod.

Comment on lines 27 to 29
export type CreateSiteResponse = z.infer< typeof CreateSiteResponseSchema >;
export type StatusResponse = z.infer< typeof StatusResponseSchema >;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export type CreateSiteResponse = z.infer< typeof CreateSiteResponseSchema >;
export type StatusResponse = z.infer< typeof StatusResponseSchema >;

Let's remove these per my other comments about not assuming the response types without validating.

Comment on lines 92 to 138
it( 'should throw LoggerError for invalid API response', async () => {
const invalidResponse = {
// Missing domain_name
atomic_site_id: mockSiteId,
};

const mockWpcom = {
req: {
post: jest.fn().mockResolvedValue( invalidResponse ),
},
};
( wpcom as jest.Mock ).mockReturnValue( mockWpcom );

await expect( uploadArchive( mockArchivePath, mockToken, mockAction ) ).rejects.toThrow(
LoggerError
);
await expect( uploadArchive( mockArchivePath, mockToken, mockAction ) ).rejects.toMatchObject(
{
message: 'Invalid API response',
action: mockAction,
}
);
} );

it( 'should throw LoggerError for response with wrong types', async () => {
const invalidResponse = {
domain_name: '', // Empty string, should fail validation
atomic_site_id: 'not-a-number', // Should be a number
};

const mockWpcom = {
req: {
post: jest.fn().mockResolvedValue( invalidResponse ),
},
};
( wpcom as jest.Mock ).mockReturnValue( mockWpcom );

await expect( uploadArchive( mockArchivePath, mockToken, mockAction ) ).rejects.toThrow(
LoggerError
);
await expect( uploadArchive( mockArchivePath, mockToken, mockAction ) ).rejects.toMatchObject(
{
message: 'Invalid API response',
action: mockAction,
}
);
} );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like these two cases are testing more or less the same thing.

Comment on lines 193 to 324
it( 'should handle validation errors', async () => {
const errorMessage = 'Validation failed';
( validateSiteFolder as jest.Mock ).mockImplementation( () => {
throw new LoggerError( errorMessage, 'validate' );
} );

const { registerCommand } = await import( '../create' );
registerCommand( program );

await program.parseAsync( [ 'node', 'test', 'go', mockFolder ] );

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockErrorData ).toHaveProperty( 'message', errorMessage );
expect( mockErrorData ).toHaveProperty( 'action', 'validate' );
expect( createArchive ).not.toHaveBeenCalled();
} );

it( 'should handle authentication errors', async () => {
const errorMessage =
'Authentication required. Please run the Studio app and authenticate first.';
( getAuthToken as jest.Mock ).mockImplementation( () => {
throw new LoggerError( errorMessage, 'validate' );
} );

const { registerCommand } = await import( '../create' );
registerCommand( program );

await program.parseAsync( [ 'node', 'test', 'go', mockFolder ] );

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockErrorData ).toHaveProperty( 'message', errorMessage );
expect( mockErrorData ).toHaveProperty( 'action', 'validate' );
expect( createArchive ).not.toHaveBeenCalled();
} );

it( 'should handle archive creation errors', async () => {
const errorMessage = 'Archive creation failed';
( createArchive as jest.Mock ).mockImplementation( () => {
throw new LoggerError( errorMessage, 'archive' );
} );

const { registerCommand } = await import( '../create' );
registerCommand( program );

await program.parseAsync( [ 'node', 'test', 'go', mockFolder ] );

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockErrorData ).toHaveProperty( 'message', errorMessage );
expect( mockErrorData ).toHaveProperty( 'action', 'archive' );
expect( uploadArchive ).not.toHaveBeenCalled();
} );

it( 'should handle upload errors', async () => {
const errorMessage = 'Upload failed';
( uploadArchive as jest.Mock ).mockImplementation( () => {
throw new LoggerError( errorMessage, 'upload' );
} );

const { registerCommand } = await import( '../create' );
registerCommand( program );

await program.parseAsync( [ 'node', 'test', 'go', mockFolder ] );

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockErrorData ).toHaveProperty( 'message', errorMessage );
expect( mockErrorData ).toHaveProperty( 'action', 'upload' );
expect( waitForSiteReady ).not.toHaveBeenCalled();
} );

it( 'should handle errors gracefully', async () => {
const mockError = new Error( 'Test error' );
( fs.createWriteStream as jest.Mock ).mockImplementation( () => {
throw mockError;
it( 'should handle site readiness errors', async () => {
const errorMessage = 'Failed to create preview site';
( waitForSiteReady as jest.Mock ).mockImplementation( () => {
throw new LoggerError( errorMessage, 'ready' );
} );

const { registerCommand } = await import( '../create' );
registerCommand( program );

await program.parseAsync( [ 'node', 'test', 'go', mockFolder ] );

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockErrorData ).toHaveProperty( 'message', errorMessage );
expect( mockErrorData ).toHaveProperty( 'action', 'ready' );
expect( addPreviewSiteToAppdata ).not.toHaveBeenCalled();
} );

it( 'should handle appdata errors', async () => {
const errorMessage = 'Failed to save to appdata';
( addPreviewSiteToAppdata as jest.Mock ).mockImplementation( () => {
throw new LoggerError( errorMessage, 'appdata' );
} );

const { registerCommand } = await import( '../create' );
registerCommand( program );

await program.parseAsync( [ 'node', 'test', 'go', mockFolder ] );

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockErrorData ).toHaveProperty( 'message', errorMessage );
expect( mockErrorData ).toHaveProperty( 'action', 'appdata' );
} );

it( 'should always clean up archive file even on error', async () => {
( uploadArchive as jest.Mock ).mockImplementation( () => {
throw new LoggerError( 'Upload failed', 'upload' );
} );

const { registerCommand } = await import( '../create' );
registerCommand( program );

await program.parseAsync( [ 'node', 'test', 'go', mockFolder ] );

expect( cleanup ).toHaveBeenCalledWith( mockArchivePath );
} );

it( 'should handle unexpected errors', async () => {
const errorMessage = 'Unexpected error';
const unexpectedError = new Error( errorMessage );
( validateSiteFolder as jest.Mock ).mockImplementation( () => {
throw unexpectedError;
} );

const { registerCommand } = await import( '../create' );
registerCommand( program );

await program.parseAsync( [ 'node', 'test', 'go', mockFolder ] );

expect( mockLogger.reportError ).toHaveBeenCalledWith( mockError.message );
expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockErrorData ).toHaveProperty( 'message', errorMessage );
expect( mockErrorData ).toHaveProperty( 'action', 'validate' );
expect( createArchive ).not.toHaveBeenCalled();
} );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not wrong per se, but this strikes me as an excessive set of test cases. I assume this test code was written by AI, and it should be easy enough to modify with AI, too. Still, I think we can reduce this to two or three test cases and it would give us the same assurances in practice.

throw new LoggerError( `Folder not found: ${ siteFolder }`, action );
}

if ( ! isWordPressDirectory( siteFolder ) && ! hasWpContentDirectory( siteFolder ) ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is never going to be true, since isWordPressDirectory checks if the folder contains a wp-content folder.

@fredrikekelund fredrikekelund merged commit 796ac38 into trunk Apr 8, 2025
8 checks passed
@fredrikekelund fredrikekelund deleted the add/studio_go_create_preview branch April 8, 2025 09:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants