diff --git a/cli/commands/ai/ask.ts b/cli/commands/ai/ask.ts new file mode 100644 index 0000000000..7ffdaf6d6f --- /dev/null +++ b/cli/commands/ai/ask.ts @@ -0,0 +1,62 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { askAI, getBasicContext, AIAssistantError } from 'common/lib/ai-assistant'; +import { SiteDetails } from 'common/types/sites'; +import { getAuthToken, getSiteByFolder } from 'cli/lib/appdata'; +import { LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand( question: string, sitePath: string ): Promise< void > { + try { + // Get authentication token + const authToken = await getAuthToken(); + + // Try to get site information, fall back to basic context + const siteDetails: SiteDetails = await getSiteByFolder( sitePath ); + const context = getBasicContext( siteDetails ); + + const response = await askAI( question, context, authToken.accessToken ); + console.log( '\n' + response.content + '\n' ); + + const quotaMessage = response.quota.userCanSendMessage + ? sprintf( + __( 'Remaining prompts: %d/%d' ), + response.quota.remaining_quota, + response.quota.max_quota + ) + : response.quota.daysUntilReset <= 0 + ? __( "You've reached your usage limit for this month. Your limit will reset today." ) + : sprintf( + __( "You've reached your usage limit for this month. Your limit will reset in %d days." ), + response.quota.daysUntilReset + ); + + console.log( `ℹ ${ quotaMessage }\n` ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + throw error; + } + + if ( error instanceof AIAssistantError ) { + throw new LoggerError( __( 'AI Assistant Error: %s', error.message ), error.cause ); + } + + throw new LoggerError( __( 'Failed to get AI response' ), error ); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'ask ', + describe: __( 'Ask a question to the AI assistant' ), + builder: ( yargs ) => { + return yargs.positional( 'question', { + describe: __( 'The question to ask the AI assistant' ), + type: 'string', + demandOption: true, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv.question as string, argv.path ); + }, + } ); +}; diff --git a/cli/commands/auth/callback.ts b/cli/commands/auth/callback.ts new file mode 100644 index 0000000000..649128e51c --- /dev/null +++ b/cli/commands/auth/callback.ts @@ -0,0 +1,164 @@ +import { __ } from '@wordpress/i18n'; +import { PROTOCOL_PREFIX } from 'common/constants'; +import WPCOM from 'wpcom'; +import { z } from 'zod'; +import { validateAccessToken } from 'cli/lib/api'; +import { saveAppdata, readAppdata, lockAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { AuthToken } from 'cli/lib/token-waiter'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +const meResponseSchema = z.object( { + ID: z.number(), + email: z.string(), + display_name: z.string(), +} ); + +export function parseAuthUrl( url: string ): { + accessToken: string; + expiresIn?: number; + state?: string; +} { + const urlObject = new URL( url ); + + if ( urlObject.protocol !== `${ PROTOCOL_PREFIX }:` || urlObject.hostname !== 'auth' ) { + throw new Error( 'Invalid URL format. Expected wpcom-local-dev://auth#...' ); + } + + const hash = urlObject.hash; + if ( ! hash || ! hash.startsWith( '#' ) ) { + throw new Error( 'No authentication data found in URL' ); + } + + const params = new URLSearchParams( hash.substring( 1 ) ); + const error = params.get( 'error' ); + const errorDescription = params.get( 'error_description' ); + + if ( error ) { + const errorMessage = errorDescription || error; + throw new Error( `Authentication failed: ${ errorMessage }` ); + } + + const accessToken = params.get( 'access_token' ); + const expiresIn = params.get( 'expires_in' ); + const state = params.get( 'state' ); + + if ( ! accessToken ) { + throw new Error( 'No access token found in URL' ); + } + + return { + accessToken, + expiresIn: expiresIn ? parseInt( expiresIn ) : undefined, + state: state || undefined, + }; +} + +export async function validateAndGetUserInfo( accessToken: string ): Promise< AuthToken > { + await validateAccessToken( accessToken ); + + const wpcom = new WPCOM( accessToken ); + const rawResponse = await wpcom.req.get( '/me?fields=ID,email,display_name' ); + const response = meResponseSchema.parse( rawResponse ); + + const expiresIn = 3600; // Default to 1 hour + const expirationTime = new Date().getTime() + expiresIn * 1000; + + return { + accessToken, + expiresIn, + expirationTime, + id: response.ID, + email: response.email, + displayName: response.display_name, + }; +} + +export async function saveAuthToken( authToken: AuthToken, logger: Logger< string > ) { + logger.reportStart( 'APPDATA_SAVE', __( 'Saving authentication…' ) ); + + try { + await lockAppdata(); + let userData; + + try { + userData = await readAppdata(); + } catch { + userData = { + version: 1, + newSites: [], + sites: [], + snapshots: [], + }; + } + + userData.authToken = { + accessToken: authToken.accessToken, + id: authToken.id, + expiresIn: authToken.expiresIn, + expirationTime: authToken.expirationTime, + email: authToken.email, + displayName: authToken.displayName, + }; + + await saveAppdata( userData ); + logger.reportSuccess( __( 'Authentication saved successfully' ) ); + } finally { + await unlockAppdata(); + } +} + +export async function runCommand( url: string ): Promise< void > { + const logger = new Logger(); + + try { + if ( ! url ) { + throw new LoggerError( __( 'No callback URL provided' ) ); + } + + logger.reportStart( 'CALLBACK_PROCESS', __( 'Processing OAuth callback…' ) ); + + const parsedAuth = parseAuthUrl( url ); + const { accessToken } = parsedAuth; + logger.reportSuccess( __( 'OAuth callback parsed successfully' ) ); + + logger.reportStart( 'TOKEN_VALIDATE', __( 'Validating access token…' ) ); + const authToken = await validateAndGetUserInfo( accessToken ); + logger.reportSuccess( __( 'Access token validated' ) ); + + await saveAuthToken( authToken, logger ); + + // Show success + logger.reportSuccess( __( 'Authentication completed successfully!' ) ); + logger.reportKeyValuePair( 'status', __( 'Authenticated' ) ); + logger.reportKeyValuePair( 'user_id', authToken.id.toString() ); + logger.reportKeyValuePair( 'email', authToken.email ); + logger.reportKeyValuePair( 'display_name', authToken.displayName ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'OAuth callback processing failed' ), error ) ); + } + + // Exit with error code to indicate failure + process.exit( 1 ); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'callback ', + describe: __( 'Handle OAuth callback (internal use only)' ), + builder: ( yargs ) => { + return yargs.positional( 'url', { + type: 'string', + description: __( 'OAuth callback URL' ), + demandOption: true, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv.url ); + }, + } ); +}; diff --git a/cli/commands/auth/login.ts b/cli/commands/auth/login.ts new file mode 100644 index 0000000000..1616cf6c3f --- /dev/null +++ b/cli/commands/auth/login.ts @@ -0,0 +1,76 @@ +import { __ } from '@wordpress/i18n'; +import { SupportedLocale } from 'common/lib/locale'; +import { getAuthenticationUrl } from 'common/lib/oauth'; +import { validateAccessToken } from 'cli/lib/api'; +import { readAppdata } from 'cli/lib/appdata'; +import { openBrowser } from 'cli/lib/browser'; +import { registerProtocolHandler, unregisterProtocolHandler } from 'cli/lib/protocol-handler'; +import { waitForAuthenticationToken, getAuthStartTimestamp } from 'cli/lib/token-waiter'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand( locale: SupportedLocale = 'en' ): Promise< void > { + const logger = new Logger(); + + try { + const existingData = await readAppdata(); + if ( existingData.authToken?.accessToken ) { + await validateAccessToken( existingData.authToken.accessToken ); + logger.reportSuccess( __( 'Already authenticated with WordPress.com' ) ); + return; + } + + logger.reportStart( 'AUTH_INIT', __( 'Starting authentication flow…' ) ); + + // Get timestamp before starting auth to detect new tokens + const authStartTime = getAuthStartTimestamp(); + + // Register CLI as temporary protocol handler + logger.reportStart( 'PROTOCOL_REGISTER', __( 'Registering CLI as protocol handler…' ) ); + await registerProtocolHandler(); + logger.reportSuccess( __( 'Protocol handler registered' ) ); + + logger.reportStart( 'BROWSER_OPEN', __( 'Opening browser for authentication…' ) ); + const authUrl = getAuthenticationUrl( locale ); + await openBrowser( authUrl ); + logger.reportSuccess( __( 'Browser opened successfully' ) ); + + console.log( __( 'Please complete authentication in your browser.' ) ); + console.log( '' ); + + const authToken = await waitForAuthenticationToken( authStartTime, 120000, logger ); + await unregisterProtocolHandler(); + logger.reportSuccess( __( 'Authentication completed successfully!' ) ); + logger.reportKeyValuePair( 'status', __( 'Authenticated' ) ); + logger.reportKeyValuePair( 'user_id', authToken.id.toString() ); + logger.reportKeyValuePair( 'email', authToken.email ); + logger.reportKeyValuePair( 'display_name', authToken.displayName ); + } catch ( error ) { + // Clean up protocol registration on error + await unregisterProtocolHandler(); + + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Authentication failed' ), error ) ); + } + throw error; + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'login', + describe: __( 'Log in to WordPress.com' ), + builder: ( yargs ) => { + return yargs.option( 'locale', { + type: 'string', + default: 'en', + description: __( 'Locale for the authentication flow' ), + } ); + }, + handler: async ( argv ) => { + await runCommand( argv.locale as SupportedLocale ); + }, + } ); +}; diff --git a/cli/commands/auth/logout.ts b/cli/commands/auth/logout.ts new file mode 100644 index 0000000000..def162b4bf --- /dev/null +++ b/cli/commands/auth/logout.ts @@ -0,0 +1,46 @@ +import { __ } from '@wordpress/i18n'; +import { readAppdata, saveAppdata, lockAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand(): Promise< void > { + const logger = new Logger(); + + logger.reportStart( 'LOGOUT', __( 'Logging out…' ) ); + + try { + await lockAppdata(); + + const userData = await readAppdata(); + + if ( ! userData.authToken ) { + logger.reportError( new LoggerError( __( 'Already logged out' ) ) ); + return; + } + + delete userData.authToken; + await saveAppdata( userData ); + + logger.reportSuccess( __( 'Successfully logged out' ) ); + logger.reportKeyValuePair( 'status', __( 'Logged out' ) ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Failed to log out' ), error ) ); + } + throw error; + } finally { + await unlockAppdata(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'logout', + describe: __( 'Log out and clear authentication' ), + handler: async () => { + await runCommand(); + }, + } ); +}; diff --git a/cli/commands/auth/status.ts b/cli/commands/auth/status.ts new file mode 100644 index 0000000000..7f3cf40e27 --- /dev/null +++ b/cli/commands/auth/status.ts @@ -0,0 +1,59 @@ +import { __ } from '@wordpress/i18n'; +import { validateAccessToken } from 'cli/lib/api'; +import { readAppdata } from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand(): Promise< void > { + const logger = new Logger(); + + try { + logger.reportStart( 'STATUS_CHECK', __( 'Checking authentication status…' ) ); + + const userData = await readAppdata(); + + if ( ! userData.authToken?.accessToken ) { + logger.reportError( new LoggerError( __( 'Not authenticated' ) ) ); + logger.reportKeyValuePair( 'status', __( 'Not authenticated' ) ); + logger.reportKeyValuePair( 'suggestion', __( 'Run "studio auth login" to authenticate' ) ); + return; + } + + try { + await validateAccessToken( userData.authToken.accessToken ); + + const WPCOM = require( 'wpcom' ); + const wpcom = new WPCOM( userData.authToken.accessToken ); + const user = await wpcom.req.get( '/me', { fields: 'ID,login,email,display_name' } ); + + logger.reportSuccess( __( 'Successfully authenticated with WordPress.com' ) ); + logger.reportKeyValuePair( 'status', __( 'Authenticated' ) ); + logger.reportKeyValuePair( 'user_id', user.ID.toString() ); + logger.reportKeyValuePair( 'username', user.login ); + logger.reportKeyValuePair( 'display_name', user.display_name ); + logger.reportKeyValuePair( 'email', user.email ); + } catch { + logger.reportError( new LoggerError( __( 'Authentication token is invalid or expired' ) ) ); + logger.reportKeyValuePair( 'status', __( 'Invalid token' ) ); + logger.reportKeyValuePair( 'suggestion', __( 'Run "studio auth login" to re-authenticate' ) ); + } + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Failed to check authentication status' ), error ) ); + } + logger.reportKeyValuePair( 'status', __( 'Error' ) ); + logger.reportKeyValuePair( 'suggestion', __( 'Run "studio auth login" to authenticate' ) ); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'status', + describe: __( 'Check authentication status' ), + handler: async () => { + await runCommand(); + }, + } ); +}; diff --git a/cli/commands/preview/create.ts b/cli/commands/preview/create.ts index adb074bd3d..ea19e71eb4 100644 --- a/cli/commands/preview/create.ts +++ b/cli/commands/preview/create.ts @@ -6,7 +6,7 @@ import { uploadArchive, waitForSiteReady } from 'cli/lib/api'; import { getAuthToken } from 'cli/lib/appdata'; import { archiveSiteContent, cleanup } from 'cli/lib/archive'; import { saveSnapshotToAppdata } from 'cli/lib/snapshots'; -import { validateSiteFolder, validateSiteSize } from 'cli/lib/validation'; +import { validateReadSitePath, validateSiteSize } from 'cli/lib/validation'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -19,8 +19,14 @@ export async function runCommand( siteFolder: string ): Promise< void > { try { logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) ); - validateSiteFolder( siteFolder ); - await validateSiteSize( siteFolder ); + const pathValidation = validateReadSitePath( siteFolder ); + if ( ! pathValidation.valid ) { + throw new LoggerError( pathValidation.error! ); + } + const sizeValidation = await validateSiteSize( siteFolder ); + if ( ! sizeValidation.valid ) { + throw new LoggerError( sizeValidation.error! ); + } const token = await getAuthToken(); logger.reportSuccess( __( 'Validation successful' ), true ); diff --git a/cli/commands/preview/list.ts b/cli/commands/preview/list.ts index a7ad51aa1d..7e08223c51 100644 --- a/cli/commands/preview/list.ts +++ b/cli/commands/preview/list.ts @@ -7,7 +7,7 @@ import { getSnapshotsFromAppdata, isSnapshotExpired, } from 'cli/lib/snapshots'; -import { validateSiteFolder } from 'cli/lib/validation'; +import { validateReadSitePath } from 'cli/lib/validation'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -16,7 +16,10 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) try { logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) ); - validateSiteFolder( siteFolder ); + const pathValidation = validateReadSitePath( siteFolder ); + if ( ! pathValidation.valid ) { + throw new LoggerError( pathValidation.error! ); + } const token = await getAuthToken(); logger.reportSuccess( __( 'Validation successful' ), true ); diff --git a/cli/commands/preview/tests/create.test.ts b/cli/commands/preview/tests/create.test.ts index fe4623eaa6..73f13a54c9 100644 --- a/cli/commands/preview/tests/create.test.ts +++ b/cli/commands/preview/tests/create.test.ts @@ -4,7 +4,7 @@ import { uploadArchive, waitForSiteReady } from 'cli/lib/api'; import { getAuthToken } from 'cli/lib/appdata'; import { archiveSiteContent, cleanup } from 'cli/lib/archive'; import { saveSnapshotToAppdata } from 'cli/lib/snapshots'; -import { validateSiteFolder } from 'cli/lib/validation'; +import { validateReadSitePath, validateSiteSize } from 'cli/lib/validation'; import { Logger, LoggerError } from 'cli/logger'; jest.mock( 'cli/lib/appdata', () => ( { @@ -54,7 +54,8 @@ describe( 'Preview Create Command', () => { ( Logger as jest.Mock ).mockReturnValue( mockLogger ); ( getAuthToken as jest.Mock ).mockResolvedValue( mockAuthToken ); - ( validateSiteFolder as jest.Mock ).mockReturnValue( true ); + ( validateReadSitePath as jest.Mock ).mockReturnValue( { valid: true } ); + ( validateSiteSize as jest.Mock ).mockResolvedValue( { valid: true } ); ( archiveSiteContent as jest.Mock ).mockResolvedValue( mockArchiver ); ( cleanup as jest.Mock ).mockImplementation( () => {} ); ( uploadArchive as jest.Mock ).mockResolvedValue( { @@ -73,7 +74,7 @@ describe( 'Preview Create Command', () => { const { runCommand } = await import( '../create' ); await runCommand( mockFolder ); - expect( validateSiteFolder ).toHaveBeenCalledWith( mockFolder ); + expect( validateReadSitePath ).toHaveBeenCalledWith( mockFolder ); expect( mockLogger.reportStart.mock.calls[ 0 ] ).toEqual( [ 'validate', 'Validating…' ] ); expect( mockLogger.reportSuccess.mock.calls[ 0 ] ).toEqual( [ 'Validation successful', true ] ); @@ -114,14 +115,12 @@ describe( 'Preview Create Command', () => { const { runCommand } = await import( '../create' ); await runCommand( process.cwd() ); - expect( validateSiteFolder ).toHaveBeenCalledWith( process.cwd() ); + expect( validateReadSitePath ).toHaveBeenCalledWith( process.cwd() ); } ); it( 'should handle validation errors', async () => { const errorMessage = 'Validation failed'; - ( validateSiteFolder as jest.Mock ).mockImplementation( () => { - throw new LoggerError( errorMessage ); - } ); + ( validateReadSitePath as jest.Mock ).mockReturnValue( { valid: false, error: errorMessage } ); const { runCommand } = await import( '../create' ); await runCommand( mockFolder ); diff --git a/cli/commands/preview/tests/list.test.ts b/cli/commands/preview/tests/list.test.ts index 7d1bc7808b..c48ee978fe 100644 --- a/cli/commands/preview/tests/list.test.ts +++ b/cli/commands/preview/tests/list.test.ts @@ -1,6 +1,6 @@ import { getAuthToken } from 'cli/lib/appdata'; import { getSnapshotsFromAppdata } from 'cli/lib/snapshots'; -import { validateSiteFolder } from 'cli/lib/validation'; +import { validateReadSitePath } from 'cli/lib/validation'; import { Logger } from 'cli/logger'; jest.mock( 'cli/lib/appdata', () => ( { @@ -51,7 +51,7 @@ describe( 'Preview List Command', () => { }; ( Logger as jest.Mock ).mockReturnValue( mockLogger ); - ( validateSiteFolder as jest.Mock ).mockReturnValue( true ); + ( validateReadSitePath as jest.Mock ).mockReturnValue( { valid: true } ); ( getAuthToken as jest.Mock ).mockResolvedValue( mockAuthToken ); ( getSnapshotsFromAppdata as jest.Mock ).mockResolvedValue( mockSnapshots ); } ); @@ -64,7 +64,7 @@ describe( 'Preview List Command', () => { const { runCommand } = await import( '../list' ); await runCommand( mockFolder, 'table' ); - expect( validateSiteFolder ).toHaveBeenCalledWith( mockFolder ); + expect( validateReadSitePath ).toHaveBeenCalledWith( mockFolder ); expect( getSnapshotsFromAppdata ).toHaveBeenCalledWith( mockAuthToken.id, mockFolder ); expect( mockLogger.reportStart.mock.calls[ 0 ] ).toEqual( [ 'validate', 'Validating…' ] ); expect( mockLogger.reportSuccess.mock.calls[ 0 ] ).toEqual( [ 'Validation successful', true ] ); @@ -77,8 +77,9 @@ describe( 'Preview List Command', () => { it( 'should handle validation errors', async () => { const { runCommand } = await import( '../list' ); - ( validateSiteFolder as jest.Mock ).mockImplementation( () => { - throw new Error( 'Invalid site folder' ); + ( validateReadSitePath as jest.Mock ).mockReturnValue( { + valid: false, + error: 'Invalid site folder', } ); await runCommand( mockFolder, 'table' ); diff --git a/cli/commands/preview/tests/update.test.ts b/cli/commands/preview/tests/update.test.ts index 66fe010f1b..99cec8a447 100644 --- a/cli/commands/preview/tests/update.test.ts +++ b/cli/commands/preview/tests/update.test.ts @@ -5,7 +5,7 @@ import { uploadArchive, waitForSiteReady } from 'cli/lib/api'; import { getAuthToken, getOrCreateSiteByFolder, getSiteByFolder } from 'cli/lib/appdata'; import { archiveSiteContent, cleanup } from 'cli/lib/archive'; import { updateSnapshotInAppdata, getSnapshotsFromAppdata } from 'cli/lib/snapshots'; -import { validateSiteFolder } from 'cli/lib/validation'; +import { validateReadSitePath } from 'cli/lib/validation'; import { Logger, LoggerError } from 'cli/logger'; jest.mock( 'cli/lib/appdata', () => ( { @@ -59,7 +59,7 @@ describe( 'Preview Update Command', () => { ( Logger as jest.Mock ).mockReturnValue( mockLogger ); ( getAuthToken as jest.Mock ).mockResolvedValue( mockAuthToken ); - ( validateSiteFolder as jest.Mock ).mockReturnValue( true ); + ( validateReadSitePath as jest.Mock ).mockReturnValue( { valid: true } ); ( getSnapshotsFromAppdata as jest.Mock ).mockResolvedValue( [ mockSnapshot ] ); ( archiveSiteContent as jest.Mock ).mockResolvedValue( undefined ); ( uploadArchive as jest.Mock ).mockResolvedValue( { @@ -83,7 +83,7 @@ describe( 'Preview Update Command', () => { const { runCommand } = await import( '../update' ); await runCommand( mockFolder, mockSiteUrl, false ); - expect( validateSiteFolder ).toHaveBeenCalledWith( mockFolder ); + expect( validateReadSitePath ).toHaveBeenCalledWith( mockFolder ); expect( mockLogger.reportStart.mock.calls[ 0 ] ).toEqual( [ 'validate', 'Validating…' ] ); expect( mockLogger.reportSuccess.mock.calls[ 0 ] ).toEqual( [ 'Validation successful', true ] ); @@ -122,14 +122,12 @@ describe( 'Preview Update Command', () => { const { runCommand } = await import( '../update' ); await runCommand( process.cwd(), mockSiteUrl, false ); - expect( validateSiteFolder ).toHaveBeenCalledWith( process.cwd() ); + expect( validateReadSitePath ).toHaveBeenCalledWith( process.cwd() ); } ); it( 'should handle validation errors', async () => { const errorMessage = 'Validation failed'; - ( validateSiteFolder as jest.Mock ).mockImplementation( () => { - throw new LoggerError( errorMessage ); - } ); + ( validateReadSitePath as jest.Mock ).mockReturnValue( { valid: false, error: errorMessage } ); const { runCommand } = await import( '../update' ); await runCommand( mockFolder, mockSiteUrl, false ); diff --git a/cli/commands/preview/update.ts b/cli/commands/preview/update.ts index 49bbecccf2..d529bf937d 100644 --- a/cli/commands/preview/update.ts +++ b/cli/commands/preview/update.ts @@ -10,7 +10,7 @@ import { getAuthToken, getOrCreateSiteByFolder, getSiteByFolder } from 'cli/lib/ import { cleanup, archiveSiteContent } from 'cli/lib/archive'; import { getSnapshotsFromAppdata, updateSnapshotInAppdata } from 'cli/lib/snapshots'; import { normalizeHostname } from 'cli/lib/utils'; -import { validateSiteFolder } from 'cli/lib/validation'; +import { validateReadSitePath } from 'cli/lib/validation'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -59,7 +59,10 @@ export async function runCommand( try { logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) ); - validateSiteFolder( siteFolder ); + const pathValidation = validateReadSitePath( siteFolder ); + if ( ! pathValidation.valid ) { + throw new LoggerError( pathValidation.error! ); + } const token = await getAuthToken(); const snapshots = await getSnapshotsFromAppdata( token.id ); const snapshotToUpdate = await getSnapshotToUpdate( snapshots, host, siteFolder, overwrite ); diff --git a/cli/commands/sites/create.ts b/cli/commands/sites/create.ts new file mode 100644 index 0000000000..a811f7c3f5 --- /dev/null +++ b/cli/commands/sites/create.ts @@ -0,0 +1,196 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { input, select, Separator } from '@inquirer/prompts'; +import { __, sprintf } from '@wordpress/i18n'; +import { portFinder } from 'common/lib/port-finder'; +import { + DEFAULT_PHP_VERSION, + ALLOWED_PHP_VERSIONS, + DEFAULT_WORDPRESS_VERSION, +} from 'common/lib/wordpress-provider/constants'; +import { setupWordPressSite } from 'common/lib/wordpress-setup'; +import { getGroupedWordPressVersions } from 'common/lib/wp-org/version-groups'; +import { fetchWordPressVersions } from 'common/lib/wp-org/versions'; +import { PreviewCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { readAppdata, saveAppdata, lockAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { validateCreateSitePath } from 'cli/lib/validation'; +import { Logger, LoggerError } from 'cli/logger'; +import { storagePaths } from 'cli/storage/paths'; +import { StudioArgv } from 'cli/types'; + +interface SiteCreationData { + name: string; + path: string; + phpVersion: string; + wpVersion: string; +} + +async function getWordPressVersionChoices() { + try { + const versions = await fetchWordPressVersions(); + const groups = getGroupedWordPressVersions( versions ); + const choices: ( { name: string; value: string } | Separator )[] = []; + groups.forEach( ( group ) => { + if ( group.versions.length > 0 ) { + choices.push( new Separator( `─── ${ group.label } ───` ) ); + group.versions.forEach( ( v ) => { + choices.push( { name: v.label, value: v.value } ); + } ); + } + } ); + + return { choices, useSelect: true }; + } catch ( error ) { + return { choices: null, useSelect: false }; + } +} + +function generateSiteNameFromPath( sitePath: string ): string { + const folderName = path.basename( path.resolve( sitePath ) ); + + // Convert folder name to a readable site name + // First handle camelCase by inserting spaces before uppercase letters + const withSpaces = folderName.replace( /([a-z])([A-Z])/g, '$1 $2' ); + + // Then split on hyphens, underscores, and spaces + return withSpaces + .split( /[-_\s]+/ ) + .filter( ( word ) => word.length > 0 ) // Remove empty strings + .map( ( word ) => word.charAt( 0 ).toUpperCase() + word.slice( 1 ).toLowerCase() ) // Capitalize each word + .join( ' ' ); // Join with spaces +} + +async function promptForSiteData(): Promise< SiteCreationData > { + const sitePath = await input( { + message: __( 'Site path:' ), + default: process.cwd(), + validate: async ( inputPath: string ) => { + return validateCreateSitePath( inputPath ).valid; + }, + } ); + + const siteName = await input( { + message: __( 'Site name:' ), + default: generateSiteNameFromPath( sitePath ), + } ); + + const phpVersion = await select( { + message: __( 'PHP version:' ), + choices: ALLOWED_PHP_VERSIONS.map( ( version ) => ( { + name: version, + value: version, + } ) ), + default: DEFAULT_PHP_VERSION, + } ); + + const wordPressVersionData = await getWordPressVersionChoices(); + let wpVersion; + if ( wordPressVersionData.useSelect && wordPressVersionData.choices ) { + wpVersion = await select( { + message: __( 'WordPress version:' ), + choices: wordPressVersionData.choices, + default: DEFAULT_WORDPRESS_VERSION, + } ); + } else { + wpVersion = await input( { + message: __( 'WordPress version:' ), + default: DEFAULT_WORDPRESS_VERSION, + } ); + } + + return { + name: siteName, + path: sitePath, + phpVersion, + wpVersion, + }; +} + +async function createSite( siteData: SiteCreationData ): Promise< void > { + const logger = new Logger< LoggerAction >(); + const resolvedPath = path.resolve( siteData.path ); + + try { + logger.reportStart( LoggerAction.APPDATA, __( 'Creating site...' ) ); + + const siteId = crypto.randomUUID(); + const port = await portFinder.getOpenPort(); + + const siteEntry = { + id: siteId, + name: siteData.name, + path: resolvedPath, + port, + phpVersion: siteData.phpVersion, + running: false, + isWpAutoUpdating: false, + }; + + if ( ! fs.existsSync( resolvedPath ) ) { + fs.mkdirSync( resolvedPath, { recursive: true } ); + } + + // Setup WordPress files in the site directory + logger.reportStart( LoggerAction.APPDATA, __( 'Setting up WordPress files...' ) ); + await setupWordPressSite( { + sitePath: resolvedPath, + wpVersion: siteData.wpVersion, + serverFilesPath: storagePaths.getServerFilesPath(), + } ); + + await lockAppdata(); + const appdata = await readAppdata(); + appdata.sites.push( siteEntry ); + await saveAppdata( appdata ); + + logger.reportSuccess( sprintf( __( 'Site "%s" created successfully' ), siteData.name ) ); + console.log( __( '\nSite details:' ) ); + console.log( sprintf( __( ' Name: %s' ), siteData.name ) ); + console.log( sprintf( __( ' Path: %s' ), resolvedPath ) ); + console.log( sprintf( __( ' WordPress Version: %s' ), siteData.wpVersion ) ); + console.log( sprintf( __( ' PHP Version: %s' ), siteData.phpVersion ) ); + console.log( sprintf( __( ' Port: %d' ), port ) ); + console.log( sprintf( __( ' ID: %s' ), siteEntry.id ) ); + + console.log( __( '\nUse "studio sites list" to see all your sites.' ) ); + } catch ( error ) { + // Clean up the directory if WordPress setup failed + if ( fs.existsSync( resolvedPath ) ) { + fs.rmSync( resolvedPath, { recursive: true, force: true } ); + } + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to create site' ), error ); + logger.reportError( loggerError ); + } + throw error; + } finally { + await unlockAppdata(); + } +} + +export async function runCommand(): Promise< void > { + try { + console.log( __( 'Create a new WordPress site' ) ); + console.log( __( 'Press Ctrl+C to cancel at any time.\n' ) ); + + const siteData = await promptForSiteData(); + await createSite( siteData ); + } catch ( error ) { + if ( error && typeof error === 'object' && 'isTTYError' in error ) { + console.error( __( 'This command requires an interactive terminal' ) ); + process.exit( 1 ); + } + throw error; + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'create', + describe: __( 'Create a new site interactively' ), + handler: runCommand, + } ); +}; diff --git a/cli/commands/sites/delete.ts b/cli/commands/sites/delete.ts new file mode 100644 index 0000000000..c0194340a5 --- /dev/null +++ b/cli/commands/sites/delete.ts @@ -0,0 +1,150 @@ +import fs from 'fs'; +import { confirm } from '@inquirer/prompts'; +import { __, sprintf } from '@wordpress/i18n'; +import { PreviewCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { + readAppdata, + saveAppdata, + lockAppdata, + unlockAppdata, + getSiteByFolder, +} from 'cli/lib/appdata'; +import { validateReadSitePath } from 'cli/lib/validation'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +interface SiteData { + id: string; + name: string; + path: string; +} + +async function deleteSiteFromAppdata( siteId: string ): Promise< void > { + try { + await lockAppdata(); + const userData = await readAppdata(); + const updatedSites = userData.sites.filter( ( site ) => site.id !== siteId ); + const updatedNewSites = userData.newSites.filter( ( site ) => site.id !== siteId ); + + await saveAppdata( { + ...userData, + sites: updatedSites, + newSites: updatedNewSites, + } ); + } finally { + await unlockAppdata(); + } +} + +async function moveToTrash( filePath: string ): Promise< boolean > { + try { + // Try to use trash package for safe deletion + const { default: trash } = await import( 'trash' ); + await trash( filePath ); + return true; + } catch ( error ) { + // Fallback: try to use fs.rm for deletion (less safe) + try { + await fs.promises.rm( filePath, { recursive: true, force: true } ); + return true; + } catch ( rmError ) { + console.warn( sprintf( __( 'Warning: Could not delete files at %s' ), filePath ) ); + return false; + } + } +} + +async function deleteSite( siteData: SiteData, deleteFiles: boolean ): Promise< void > { + const logger = new Logger< LoggerAction >(); + + try { + logger.reportStart( LoggerAction.APPDATA, __( 'Deleting site...' ) ); + + // Delete from appdata + await deleteSiteFromAppdata( siteData.id ); + + // Delete files if requested + if ( deleteFiles ) { + const filesDeleted = await moveToTrash( siteData.path ); + if ( filesDeleted ) { + logger.reportSuccess( sprintf( __( 'Site files moved to trash' ) ) ); + } + } + + logger.reportSuccess( sprintf( __( 'Site "%s" deleted successfully' ), siteData.name ) ); + console.log( __( '\nUse "studio sites list" to see your remaining sites.' ) ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to delete site' ), error ); + logger.reportError( loggerError ); + } + throw error; + } +} + +export async function runCommand( sitePath: string ): Promise< void > { + try { + console.log( __( 'Delete site from Studio' ) ); + console.log( __( 'Press Ctrl+C to cancel at any time.\n' ) ); + + // Validate site path + const pathValidation = validateReadSitePath( sitePath, true ); + if ( ! pathValidation.valid ) { + throw new LoggerError( pathValidation.error! ); + } + + // Find site in appdata + const siteData = await getSiteByFolder( sitePath ); + + // Show site details + console.log( __( 'Site details:' ) ); + console.log( sprintf( __( ' Name: %s' ), siteData.name ) ); + console.log( sprintf( __( ' Path: %s' ), siteData.path ) ); + console.log( sprintf( __( ' ID: %s' ), siteData.id ) ); + console.log(); + + // Confirm deletion + const confirmDelete = await confirm( { + message: __( '⚠️ Are you sure you want to delete this site?' ), + default: false, + } ); + + if ( ! confirmDelete ) { + console.log( __( 'Operation cancelled.' ) ); + return; + } + + // Ask about file deletion + const deleteFiles = await confirm( { + message: __( '⚠️ Do you want to delete all site files? This will move them to trash.' ), + default: false, + } ); + + await deleteSite( siteData, deleteFiles ); + } catch ( error ) { + if ( error && typeof error === 'object' && 'isTTYError' in error ) { + console.error( __( 'This command requires an interactive terminal' ) ); + process.exit( 1 ); + } + + if ( error instanceof LoggerError ) { + console.error( error.message ); + process.exit( 1 ); + } + + console.error( __( 'An unexpected error occurred:' ), error ); + process.exit( 1 ); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'delete', + describe: __( 'Delete a site from Studio' ), + handler: async ( argv ) => { + await runCommand( argv.path ); + }, + } ); +}; diff --git a/cli/commands/sites/list.ts b/cli/commands/sites/list.ts new file mode 100644 index 0000000000..e7d3fd3d5b --- /dev/null +++ b/cli/commands/sites/list.ts @@ -0,0 +1,92 @@ +import { __, _n, sprintf } from '@wordpress/i18n'; +import Table from 'cli-table3'; +import { PreviewCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { readAppdata } from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +interface SiteData { + id: string; + name: string; + path: string; +} + +function getSitesCliTable( sites: SiteData[] ) { + const table = new Table( { + head: [ __( 'Name' ), __( 'Path' ), __( 'ID' ) ], + style: { + head: [ 'cyan' ], + border: [ 'grey' ], + }, + wordWrap: true, + wrapOnWordBoundary: false, + } ); + + sites.forEach( ( site ) => { + table.push( [ site.name, site.path, site.id ] ); + } ); + + return table; +} + +function getSitesCliJson( sites: SiteData[] ): SiteData[] { + return sites.map( ( site ) => ( { + id: site.id, + name: site.name, + path: site.path, + } ) ); +} + +export async function runCommand( format: 'table' | 'json' ): Promise< void > { + const logger = new Logger< LoggerAction >(); + + try { + logger.reportStart( LoggerAction.LOAD, __( 'Loading sites…' ) ); + const appdata = await readAppdata(); + + const allSites = appdata.sites; + if ( allSites.length === 0 ) { + logger.reportSuccess( __( 'No sites found' ) ); + return; + } + + const sitesMessage = sprintf( + _n( 'Found %d site', 'Found %d sites', allSites.length ), + allSites.length + ); + + logger.reportSuccess( sitesMessage ); + + if ( format === 'table' ) { + const table = getSitesCliTable( allSites ); + console.log( table.toString() ); + } else { + console.log( JSON.stringify( getSitesCliJson( allSites ), null, 2 ) ); + } + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to load sites' ), error ); + logger.reportError( loggerError ); + } + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'list', + describe: __( 'List local sites' ), + builder: ( yargs ) => { + return yargs.option( 'format', { + type: 'string', + choices: [ 'table', 'json' ], + default: 'table', + description: __( 'Output format' ), + } ); + }, + handler: async ( argv ) => { + await runCommand( argv.format as 'table' | 'json' ); + }, + } ); +}; diff --git a/cli/commands/sites/start.ts b/cli/commands/sites/start.ts new file mode 100644 index 0000000000..eab44f7280 --- /dev/null +++ b/cli/commands/sites/start.ts @@ -0,0 +1,73 @@ +import { confirm } from '@inquirer/prompts'; +import { __ } from '@wordpress/i18n'; +import { startSite } from 'common/lib/site-server'; +import { PreviewCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { readAppdata, saveAppdata, lockAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { openBrowser } from 'cli/lib/browser'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand( sitePath: string ): Promise< void > { + const logger = new Logger< LoggerAction >(); + + try { + logger.reportStart( LoggerAction.LOAD, __( 'Loading site details…' ) ); + + await lockAppdata(); + const appdata = await readAppdata(); + const site = appdata.sites.find( ( s ) => s.path === sitePath ); + if ( ! site ) { + throw new Error( __( 'No site found at this path. Use "studio sites create" first.' ) ); + } + logger.reportSuccess( __( 'Site details loaded' ) ); + + if ( site.running && site.pid ) { + logger.reportSuccess( + __( 'Site is already running at ' ) + `http://localhost:${ site.port }` + ); + return; + } + + logger.reportStart( LoggerAction.APPDATA, __( 'Starting site server…' ) ); + + // Start the site + const result = await startSite( site ); + + // Update appdata with new running state and PID + site.running = true; + site.pid = result.pid; + site.url = `http://localhost:${ site.port }`; + await saveAppdata( appdata ); + + logger.reportSuccess( __( 'Site started successfully at ' ) + site.url ); + + const shouldOpenBrowser = await confirm( { + message: __( 'Would you like to open the site in your browser?' ), + default: true, + } ); + + if ( shouldOpenBrowser ) { + await openBrowser( site.url ); + logger.reportSuccess( __( 'Site opened in browser' ) ); + } + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to start site' ), error ); + logger.reportError( loggerError ); + } + } finally { + await unlockAppdata(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'start', + describe: __( 'Start a local site' ), + handler: async ( argv ) => { + await runCommand( argv.path ); + }, + } ); +}; diff --git a/cli/commands/sites/stop.ts b/cli/commands/sites/stop.ts new file mode 100644 index 0000000000..a5cecc64d7 --- /dev/null +++ b/cli/commands/sites/stop.ts @@ -0,0 +1,57 @@ +import { __ } from '@wordpress/i18n'; +import { stopSite } from 'common/lib/site-server'; +import { PreviewCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { readAppdata, saveAppdata, lockAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand( sitePath: string ): Promise< void > { + const logger = new Logger< LoggerAction >(); + + try { + logger.reportStart( LoggerAction.LOAD, __( 'Loading site details…' ) ); + + await lockAppdata(); + const appdata = await readAppdata(); + const site = appdata.sites.find( ( s ) => s.path === sitePath ); + if ( ! site ) { + throw new Error( __( 'No site found at this path. Use "studio sites create" first.' ) ); + } + logger.reportSuccess( __( 'Site details loaded' ) ); + + if ( ! site.running ) { + logger.reportSuccess( __( 'Site is already stopped' ) ); + return; + } + + logger.reportStart( LoggerAction.APPDATA, __( 'Stopping site server…' ) ); + await stopSite( site ); + + // Update appdata with stopped state + site.running = false; + delete site.pid; + delete site.url; + await saveAppdata( appdata ); + + logger.reportSuccess( __( 'Site stopped successfully' ) ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to stop site' ), error ); + logger.reportError( loggerError ); + } + } finally { + await unlockAppdata(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'stop', + describe: __( 'Stop a local site' ), + handler: async ( argv ) => { + await runCommand( argv.path ); + }, + } ); +}; diff --git a/cli/commands/sites/tests/create.test.ts b/cli/commands/sites/tests/create.test.ts new file mode 100644 index 0000000000..95553655fd --- /dev/null +++ b/cli/commands/sites/tests/create.test.ts @@ -0,0 +1,194 @@ +import fs from 'fs'; +import { input, select } from '@inquirer/prompts'; +import { fetchWordPressVersions } from 'common/lib/wp-org/versions'; +import { readAppdata, saveAppdata, lockAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { Logger } from 'cli/logger'; + +jest.mock( 'fs' ); +jest.mock( '@inquirer/prompts' ); +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + readAppdata: jest.fn(), + saveAppdata: jest.fn(), + lockAppdata: jest.fn(), + unlockAppdata: jest.fn(), +} ) ); +jest.mock( 'cli/logger' ); +jest.mock( 'common/lib/wp-org/versions', () => ( { + fetchWordPressVersions: jest.fn(), +} ) ); + +describe( 'Sites Create Command', () => { + const mockSiteData = { + name: 'Test Site', + path: '/test/path', + phpVersion: '8.3', + wpVersion: 'latest', + }; + + const mockSite = { + id: 'test-site-id', + name: 'test-path', + path: '/test/path', + }; + + const mockAppdata = { + sites: [], + newSites: [ mockSite ], + snapshots: [], + }; + + let mockLogger: { + reportStart: jest.Mock; + reportSuccess: jest.Mock; + reportError: jest.Mock; + }; + + beforeEach( () => { + jest.clearAllMocks(); + + mockLogger = { + reportStart: jest.fn(), + reportSuccess: jest.fn(), + reportError: jest.fn(), + }; + + ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + // Mock individual prompt functions + ( input as jest.Mock ) + .mockResolvedValueOnce( mockSiteData.path ) // Site path + .mockResolvedValueOnce( mockSiteData.name ); // Site name + ( select as jest.Mock ) + .mockResolvedValueOnce( mockSiteData.phpVersion ) // PHP version + .mockResolvedValueOnce( mockSiteData.wpVersion ); // WordPress version + ( readAppdata as jest.Mock ).mockResolvedValue( { ...mockAppdata } ); // Clone to avoid mutation + ( lockAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( unlockAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( saveAppdata as jest.Mock ).mockResolvedValue( undefined ); + + // Mock WordPress versions API to return successful data + ( fetchWordPressVersions as jest.Mock ).mockResolvedValue( [ + { isBeta: false, isDevelopment: false, label: 'latest', value: 'latest' }, + { isBeta: false, isDevelopment: false, label: '6.4', value: '6.4.3' }, + { isBeta: false, isDevelopment: false, label: '6.3', value: '6.3.4' }, + ] ); + + // Mock fs methods + ( fs.existsSync as jest.Mock ).mockReturnValue( false ); + ( fs.mkdirSync as jest.Mock ).mockReturnValue( undefined ); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + it( 'should create a site successfully with basic data', async () => { + // Ensure mocks are properly reset and configured for this test + ( input as jest.Mock ) + .mockReset() + .mockResolvedValueOnce( '/test/path' ) + .mockResolvedValueOnce( 'Test Site' ); + ( select as jest.Mock ) + .mockReset() + .mockResolvedValueOnce( '8.3' ) + .mockResolvedValueOnce( 'latest' ); + + const { runCommand } = await import( '../create' ); + await runCommand(); + + expect( input ).toHaveBeenCalledTimes( 2 ); // path, name + expect( select ).toHaveBeenCalledTimes( 2 ); // phpVersion, wpVersion + expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'appdata', 'Creating site...' ); + expect( saveAppdata ).toHaveBeenCalled(); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( + 'Site "Test Site" created successfully' + ); + } ); + + it( 'should create directory if it does not exist', async () => { + ( input as jest.Mock ) + .mockReset() + .mockResolvedValueOnce( '/test/path' ) + .mockResolvedValueOnce( 'Test Site' ); + ( select as jest.Mock ) + .mockReset() + .mockResolvedValueOnce( '8.3' ) + .mockResolvedValueOnce( 'latest' ); + ( fs.existsSync as jest.Mock ).mockReturnValue( false ); + + const { runCommand } = await import( '../create' ); + await runCommand(); + + expect( fs.mkdirSync ).toHaveBeenCalledWith( '/test/path', { recursive: true } ); + } ); + + it( 'should update site name if different from folder name', async () => { + ( input as jest.Mock ) + .mockReset() + .mockResolvedValueOnce( '/test/path' ) + .mockResolvedValueOnce( 'Test Site' ); + ( select as jest.Mock ) + .mockReset() + .mockResolvedValueOnce( '8.3' ) + .mockResolvedValueOnce( 'latest' ); + + const { runCommand } = await import( '../create' ); + await runCommand(); + + expect( lockAppdata ).toHaveBeenCalled(); + expect( readAppdata ).toHaveBeenCalled(); + // Check that saveAppdata was called and a site was added + expect( saveAppdata ).toHaveBeenCalled(); + const saveCall = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( saveCall.newSites ).toContainEqual( { + id: expect.any( String ), + name: 'Test Site', + path: '/test/path', + } ); + expect( unlockAppdata ).toHaveBeenCalled(); + } ); + + it( 'should handle TTY errors gracefully', async () => { + jest.clearAllMocks(); // Clear previous mock setup + + const ttyError: Error & { isTTYError?: boolean } = new Error( 'TTY Error' ); + ttyError.isTTYError = true; + + // Mock the first input function (site path) to reject with TTY error + ( input as jest.Mock ).mockReset().mockRejectedValueOnce( ttyError ); + + const consoleErrorSpy = jest.spyOn( console, 'error' ).mockImplementation(); + const processExitSpy = jest.spyOn( process, 'exit' ).mockImplementation( () => { + throw new Error( 'process.exit' ); + } ); + + const { runCommand } = await import( '../create' ); + + await expect( runCommand() ).rejects.toThrow( 'process.exit' ); + expect( consoleErrorSpy ).toHaveBeenCalledWith( + 'This command requires an interactive terminal' + ); + expect( processExitSpy ).toHaveBeenCalledWith( 1 ); + + consoleErrorSpy.mockRestore(); + processExitSpy.mockRestore(); + } ); + + it( 'should handle site creation errors', async () => { + const error = new Error( 'Creation failed' ); + ( input as jest.Mock ) + .mockReset() + .mockResolvedValueOnce( '/test/path' ) + .mockResolvedValueOnce( 'Test Site' ); + ( select as jest.Mock ) + .mockReset() + .mockResolvedValueOnce( '8.3' ) + .mockResolvedValueOnce( 'latest' ); + ( saveAppdata as jest.Mock ).mockRejectedValue( error ); + + const { runCommand } = await import( '../create' ); + + await expect( runCommand() ).rejects.toThrow( 'Creation failed' ); + expect( mockLogger.reportError ).toHaveBeenCalled(); + } ); +} ); diff --git a/cli/commands/sites/tests/delete.test.ts b/cli/commands/sites/tests/delete.test.ts new file mode 100644 index 0000000000..0fbe71d4a5 --- /dev/null +++ b/cli/commands/sites/tests/delete.test.ts @@ -0,0 +1,215 @@ +import { confirm } from '@inquirer/prompts'; +import { + readAppdata, + saveAppdata, + lockAppdata, + unlockAppdata, + getSiteByFolder, +} from 'cli/lib/appdata'; +import { validateReadSitePath } from 'cli/lib/validation'; +import { Logger } from 'cli/logger'; + +jest.mock( '@inquirer/prompts' ); +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + readAppdata: jest.fn(), + saveAppdata: jest.fn(), + lockAppdata: jest.fn(), + unlockAppdata: jest.fn(), + getSiteByFolder: jest.fn(), +} ) ); +jest.mock( 'cli/lib/validation' ); +jest.mock( 'cli/logger' ); +jest.mock( 'trash' ); + +describe( 'Sites Delete Command', () => { + const mockSitePath = '/test/site'; + const mockSiteData = { + id: 'test-site-id', + name: 'Test Site', + path: mockSitePath, + }; + const mockAppdata = { + sites: [ mockSiteData ], + newSites: [], + snapshots: [], + }; + + let mockLogger: { + reportStart: jest.Mock; + reportSuccess: jest.Mock; + reportError: jest.Mock; + }; + + beforeEach( () => { + jest.clearAllMocks(); + jest.spyOn( console, 'log' ).mockImplementation( () => {} ); + jest.spyOn( console, 'error' ).mockImplementation( () => {} ); + jest.spyOn( process, 'exit' ).mockImplementation( ( code ) => { + throw new Error( `Process exited with code ${ code }` ); + } ); + + mockLogger = { + reportStart: jest.fn(), + reportSuccess: jest.fn(), + reportError: jest.fn(), + }; + + ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + ( validateReadSitePath as jest.Mock ).mockReturnValue( { valid: true } ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( readAppdata as jest.Mock ).mockResolvedValue( { ...mockAppdata } ); + ( lockAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( unlockAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( saveAppdata as jest.Mock ).mockResolvedValue( undefined ); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + it( 'should delete site successfully without deleting files', async () => { + ( confirm as jest.Mock ) + .mockResolvedValueOnce( true ) // Confirm deletion + .mockResolvedValueOnce( false ); // Don't delete files + + const { runCommand } = await import( '../delete' ); + await runCommand( mockSitePath ); + + expect( validateReadSitePath ).toHaveBeenCalledWith( mockSitePath ); + expect( getSiteByFolder ).toHaveBeenCalledWith( mockSitePath ); + expect( confirm ).toHaveBeenCalledTimes( 2 ); + expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'appdata', 'Deleting site...' ); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( + 'Site "Test Site" deleted successfully' + ); + expect( saveAppdata ).toHaveBeenCalledWith( { + ...mockAppdata, + sites: [], + newSites: [], + } ); + } ); + + it( 'should delete site and files when confirmed', async () => { + const mockTrash = jest.fn().mockResolvedValue( undefined ); + jest.doMock( 'trash', () => ( { default: mockTrash } ) ); + + ( confirm as jest.Mock ) + .mockResolvedValueOnce( true ) // Confirm deletion + .mockResolvedValueOnce( true ); // Delete files + + const { runCommand } = await import( '../delete' ); + await runCommand( mockSitePath ); + + expect( validateReadSitePath ).toHaveBeenCalledWith( mockSitePath ); + expect( getSiteByFolder ).toHaveBeenCalledWith( mockSitePath ); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site files moved to trash' ); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( + 'Site "Test Site" deleted successfully' + ); + } ); + + it( 'should handle validation errors', async () => { + ( validateReadSitePath as jest.Mock ).mockReturnValue( { + valid: false, + error: 'Invalid WordPress directory', + } ); + + const { runCommand } = await import( '../delete' ); + + await expect( () => runCommand( mockSitePath ) ).rejects.toThrow( + 'Process exited with code 1' + ); + expect( getSiteByFolder ).not.toHaveBeenCalled(); + expect( saveAppdata ).not.toHaveBeenCalled(); + } ); + + it( 'should handle site not found error', async () => { + ( getSiteByFolder as jest.Mock ).mockRejectedValue( + new Error( 'The specified folder is not added to Studio.' ) + ); + + const { runCommand } = await import( '../delete' ); + + await expect( () => runCommand( mockSitePath ) ).rejects.toThrow( + 'Process exited with code 1' + ); + expect( saveAppdata ).not.toHaveBeenCalled(); + } ); + + it( 'should cancel when user declines confirmation', async () => { + ( confirm as jest.Mock ).mockResolvedValueOnce( false ); // Don't confirm deletion + + const { runCommand } = await import( '../delete' ); + await runCommand( mockSitePath ); + + expect( confirm ).toHaveBeenCalledTimes( 1 ); + expect( mockLogger.reportStart ).not.toHaveBeenCalled(); + expect( saveAppdata ).not.toHaveBeenCalled(); + } ); + + it( 'should handle file deletion errors gracefully', async () => { + const mockTrash = jest.fn().mockRejectedValue( new Error( 'Trash failed' ) ); + jest.doMock( 'trash', () => ( { default: mockTrash } ) ); + + // Mock fs.promises.rm to also fail + const mockFsRm = jest.fn().mockRejectedValue( new Error( 'FS remove failed' ) ); + jest.doMock( + 'fs', + () => ( { + promises: { rm: mockFsRm }, + } ), + { virtual: true } + ); + + ( confirm as jest.Mock ) + .mockResolvedValueOnce( true ) // Confirm deletion + .mockResolvedValueOnce( true ); // Delete files + + const { runCommand } = await import( '../delete' ); + await runCommand( mockSitePath ); + + // Should still succeed even if file deletion fails + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( + 'Site "Test Site" deleted successfully' + ); + expect( saveAppdata ).toHaveBeenCalled(); + } ); + + it( 'should remove site from both sites and newSites arrays', async () => { + const mockAppdataWithNewSites = { + sites: [ mockSiteData ], + newSites: [ { ...mockSiteData, id: 'other-id' } ], + snapshots: [], + }; + ( readAppdata as jest.Mock ).mockResolvedValue( mockAppdataWithNewSites ); + + ( confirm as jest.Mock ) + .mockResolvedValueOnce( true ) // Confirm deletion + .mockResolvedValueOnce( false ); // Don't delete files + + const { runCommand } = await import( '../delete' ); + await runCommand( mockSitePath ); + + expect( saveAppdata ).toHaveBeenCalledWith( { + ...mockAppdataWithNewSites, + sites: [], + newSites: [ { ...mockSiteData, id: 'other-id' } ], + } ); + } ); + + it( 'should handle TTY errors gracefully', async () => { + const ttyError: Error & { isTTYError?: boolean } = new Error( 'TTY Error' ); + ttyError.isTTYError = true; + ( validateReadSitePath as jest.Mock ).mockImplementation( () => { + throw ttyError; + } ); + + const { runCommand } = await import( '../delete' ); + + await expect( () => runCommand( mockSitePath ) ).rejects.toThrow( + 'Process exited with code 1' + ); + expect( console.error ).toHaveBeenCalledWith( 'This command requires an interactive terminal' ); + } ); +} ); diff --git a/cli/commands/sites/tests/list.test.ts b/cli/commands/sites/tests/list.test.ts new file mode 100644 index 0000000000..3ee8f2f262 --- /dev/null +++ b/cli/commands/sites/tests/list.test.ts @@ -0,0 +1,110 @@ +import { readAppdata } from 'cli/lib/appdata'; +import { Logger } from 'cli/logger'; + +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ), + readAppdata: jest.fn(), +} ) ); +jest.mock( 'cli/logger' ); + +describe( 'Sites List Command', () => { + const mockAppdata = { + sites: [ + { + id: 'site-1', + name: 'Test Site 1', + path: '/path/to/site1', + }, + { + id: 'site-2', + name: 'Test Site 2', + path: '/path/to/site2', + }, + ], + newSites: [ + { + id: 'new-site-1', + name: 'New Test Site', + path: '/path/to/newsite', + }, + ], + snapshots: [], + }; + + let mockLogger: { + reportStart: jest.Mock; + reportSuccess: jest.Mock; + reportError: jest.Mock; + }; + + beforeEach( () => { + jest.clearAllMocks(); + + mockLogger = { + reportStart: jest.fn(), + reportSuccess: jest.fn(), + reportError: jest.fn(), + }; + + ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + ( readAppdata as jest.Mock ).mockResolvedValue( mockAppdata ); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + it( 'should list sites successfully', async () => { + const { runCommand } = await import( '../list' ); + await runCommand( 'table' ); + + expect( readAppdata ).toHaveBeenCalled(); + expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'load', 'Loading sites…' ); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Found 3 sites' ); + } ); + + it( 'should handle no sites found', async () => { + const { runCommand } = await import( '../list' ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [], + newSites: [], + snapshots: [], + } ); + + await runCommand( 'table' ); + + expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'load', 'Loading sites…' ); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'No sites found' ); + } ); + + it( 'should handle appdata read errors', async () => { + const { runCommand } = await import( '../list' ); + ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Failed to read appdata' ) ); + + await runCommand( 'table' ); + + expect( mockLogger.reportError ).toHaveBeenCalled(); + } ); + + it( 'should work with json format', async () => { + const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); + const { runCommand } = await import( '../list' ); + await runCommand( 'json' ); + + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Found 3 sites' ); + expect( consoleSpy ).toHaveBeenCalledWith( + JSON.stringify( + [ + { id: 'site-1', name: 'Test Site 1', path: '/path/to/site1' }, + { id: 'site-2', name: 'Test Site 2', path: '/path/to/site2' }, + { id: 'new-site-1', name: 'New Test Site', path: '/path/to/newsite' }, + ], + null, + 2 + ) + ); + + consoleSpy.mockRestore(); + } ); +} ); diff --git a/cli/index.ts b/cli/index.ts index 5bedc79b59..97c5c6bbf9 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -3,11 +3,22 @@ import { __ } from '@wordpress/i18n'; import { suppressPunycodeWarning } from 'common/lib/suppress-punycode-warning'; import { StatsGroup, StatsMetric } from 'common/types/stats'; import yargs from 'yargs'; +import { registerCommand as registerAiAskCommand } from 'cli/commands/ai/ask'; +import { registerCommand as registerAuthCallbackCommand } from 'cli/commands/auth/callback'; +import { registerCommand as registerAuthLoginCommand } from 'cli/commands/auth/login'; +import { registerCommand as registerAuthLogoutCommand } from 'cli/commands/auth/logout'; +import { registerCommand as registerAuthStatusCommand } from 'cli/commands/auth/status'; 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 registerUpdateCommand } from 'cli/commands/preview/update'; +import { registerCommand as registerSitesCreateCommand } from 'cli/commands/sites/create'; +import { registerCommand as registerSitesDeleteCommand } from 'cli/commands/sites/delete'; +import { registerCommand as registerSitesListCommand } from 'cli/commands/sites/list'; +import { registerCommand as registerSitesStartCommand } from 'cli/commands/sites/start'; +import { registerCommand as registerSitesStopCommand } from 'cli/commands/sites/stop'; import { loadTranslations } from 'cli/lib/i18n'; +import { ensureResourcesAvailable } from 'cli/lib/resources'; import { bumpAggregatedUniqueStat } from 'cli/lib/stats'; import { version } from 'cli/package.json'; import { StudioArgv } from 'cli/types'; @@ -15,6 +26,8 @@ import { StudioArgv } from 'cli/types'; suppressPunycodeWarning(); async function main() { + await ensureResourcesAvailable(); + const locale = await loadTranslations(); const studioArgv: StudioArgv = yargs( process.argv.slice( 2 ) ) @@ -42,6 +55,17 @@ async function main() { ); } } ) + .command( 'ai', __( 'AI assistant commands' ), ( aiYargs ) => { + registerAiAskCommand( aiYargs ); + aiYargs.demandCommand( 1, __( 'You must provide a valid AI command' ) ); + } ) + .command( 'auth', __( 'Manage authentication' ), ( authYargs ) => { + registerAuthLoginCommand( authYargs ); + registerAuthStatusCommand( authYargs ); + registerAuthLogoutCommand( authYargs ); + registerAuthCallbackCommand( authYargs ); + authYargs.demandCommand( 1, __( 'You must provide a valid auth command' ) ); + } ) .command( 'preview', __( 'Manage preview sites' ), ( previewYargs ) => { registerCreateCommand( previewYargs ); registerListCommand( previewYargs ); @@ -49,6 +73,14 @@ async function main() { registerUpdateCommand( previewYargs ); previewYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); } ) + .command( 'sites', __( 'Manage local sites' ), ( sitesYargs ) => { + registerSitesListCommand( sitesYargs ); + registerSitesCreateCommand( sitesYargs ); + registerSitesDeleteCommand( sitesYargs ); + registerSitesStartCommand( sitesYargs ); + registerSitesStopCommand( sitesYargs ); + sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); + } ) .demandCommand( 1, __( 'You must provide a valid command' ) ) .strict(); diff --git a/cli/lib/appdata.ts b/cli/lib/appdata.ts index db9877ba94..c33f644da4 100644 --- a/cli/lib/appdata.ts +++ b/cli/lib/appdata.ts @@ -1,26 +1,18 @@ import crypto from 'crypto'; import fs from 'fs'; -import os from 'os'; import path from 'path'; -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { readFile, writeFile } from 'atomically'; import { LOCKFILE_NAME, LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from 'common/constants'; import { arePathsEqual } from 'common/lib/fs-utils'; import { lockFileAsync, unlockFileAsync } from 'common/lib/lockfile'; -import { getAuthenticationUrl } from 'common/lib/oauth'; +import { siteSchema, type SiteDetails } from 'common/types/sites'; import { snapshotSchema } from 'common/types/snapshot'; import { StatsMetric } from 'common/types/stats'; import { z } from 'zod'; import { validateAccessToken } from 'cli/lib/api'; import { LoggerError } from 'cli/logger'; - -const siteSchema = z - .object( { - id: z.string(), - path: z.string(), - name: z.string(), - } ) - .passthrough(); +import { storagePaths } from 'cli/storage/paths'; const userDataSchema = z .object( { @@ -32,6 +24,10 @@ const userDataSchema = z .object( { accessToken: z.string().min( 1, __( 'Access token cannot be empty' ) ), id: z.number(), + expiresIn: z.number(), + expirationTime: z.number(), + email: z.string().email(), + displayName: z.string(), } ) .passthrough() .optional(), @@ -42,18 +38,9 @@ const userDataSchema = z .passthrough(); type UserData = z.infer< typeof userDataSchema >; -type SiteData = z.infer< typeof siteSchema >; export function getAppdataDirectory(): string { - if ( process.platform === 'win32' ) { - if ( ! process.env.APPDATA ) { - throw new LoggerError( __( 'Studio config file path not found.' ) ); - } - - return path.join( process.env.APPDATA, 'Studio' ); - } - - return path.join( os.homedir(), 'Library', 'Application Support', 'Studio' ); + return storagePaths.getStudioDataPath(); } export function getAppdataPath(): string { @@ -113,7 +100,7 @@ export async function saveAppdata( userData: UserData ): Promise< void > { } } -const LOCKFILE_PATH = path.join( getAppdataDirectory(), LOCKFILE_NAME ); +const LOCKFILE_PATH = path.join( storagePaths.getStudioDataPath(), LOCKFILE_NAME ); export async function lockAppdata(): Promise< void > { await lockFileAsync( LOCKFILE_PATH, { wait: LOCKFILE_WAIT_TIME, stale: LOCKFILE_STALE_TIME } ); @@ -135,19 +122,15 @@ export async function getAuthToken(): Promise< NonNullable< UserData[ 'authToken return authToken; } catch ( error ) { - const authUrl = getAuthenticationUrl( 'en' ); - throw new LoggerError( - sprintf( - // translators: %s is a URL to log in to WordPress.com - __( 'Authentication required. Please log in to WordPress.com first:\n%s' ), - authUrl + __( + 'Authentication required. Please run "studio auth login" to authenticate with WordPress.com.' ) ); } } -export async function getSiteByFolder( siteFolder: string ): Promise< SiteData > { +export async function getSiteByFolder( siteFolder: string ): Promise< SiteDetails > { const userData = await readAppdata(); const site = [ ...userData.sites, ...userData.newSites ].find( ( site ) => arePathsEqual( site.path, siteFolder ) @@ -160,7 +143,7 @@ export async function getSiteByFolder( siteFolder: string ): Promise< SiteData > return site; } -export function getNewSitePartial( siteFolder: string ): SiteData { +export function getNewSitePartial( siteFolder: string ): SiteDetails { const newSite = { id: crypto.randomUUID(), path: siteFolder, @@ -170,7 +153,7 @@ export function getNewSitePartial( siteFolder: string ): SiteData { return newSite; } -const createNewSite = async ( siteFolder: string ): Promise< SiteData > => { +const createNewSite = async ( siteFolder: string ): Promise< SiteDetails > => { try { await lockAppdata(); const userData = await readAppdata(); @@ -183,7 +166,7 @@ const createNewSite = async ( siteFolder: string ): Promise< SiteData > => { } }; -export const getOrCreateSiteByFolder = async ( siteFolder: string ): Promise< SiteData > => { +export const getOrCreateSiteByFolder = async ( siteFolder: string ): Promise< SiteDetails > => { try { return await getSiteByFolder( siteFolder ); } catch ( error ) { diff --git a/cli/lib/browser.ts b/cli/lib/browser.ts new file mode 100644 index 0000000000..20f582aa72 --- /dev/null +++ b/cli/lib/browser.ts @@ -0,0 +1,45 @@ +import { spawn } from 'child_process'; +import { __ } from '@wordpress/i18n'; +import { LoggerError } from 'cli/logger'; + +/** + * Opens the default browser with the specified URL + */ +export async function openBrowser( url: string ): Promise< void > { + const platform = process.platform; + let cmd: string; + let args: string[]; + + switch ( platform ) { + case 'darwin': + cmd = 'open'; + args = [ url ]; + break; + case 'win32': + cmd = 'rundll32'; + args = [ 'url.dll,FileProtocolHandler', url ]; + break; + default: + cmd = 'xdg-open'; + args = [ url ]; + break; + } + + return new Promise( ( resolve, reject ) => { + const child = spawn( cmd, args ); + + child.on( 'error', ( error ) => { + reject( + new LoggerError( __( 'Failed to open browser. Please open the URL manually.' ), error ) + ); + } ); + + child.on( 'exit', ( code ) => { + if ( code === 0 ) { + resolve(); + } else { + reject( new LoggerError( __( 'Failed to open browser. Please open the URL manually.' ) ) ); + } + } ); + } ); +} diff --git a/cli/lib/protocol-handler.ts b/cli/lib/protocol-handler.ts new file mode 100644 index 0000000000..6d45ed8ecc --- /dev/null +++ b/cli/lib/protocol-handler.ts @@ -0,0 +1,393 @@ +import { spawn } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { __ } from '@wordpress/i18n'; +import { PROTOCOL_PREFIX } from 'common/constants'; +import plist from 'plist'; +import { LoggerError } from 'cli/logger'; + +// Constants +const LSREGISTER_PATH = + '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'; +const BUNDLE_ID_PREFIX = 'com.automattic.studio-cli-auth'; + +// Store registration state for cleanup +let protocolRegistrationPath: string | null = null; + +// Store original protocol handler state for restoration +let originalHandlerState: { + appPath: string; + bundleId: string; +} | null = null; + +/** + * Register the CLI as the protocol handler, saving existing handlers for restoration + * @param override - If true, will override existing protocol handlers. If false, will throw error if protocol already exists. + */ +export async function registerProtocolHandler( override: boolean = true ): Promise< void > { + try { + const defaultApp = await getDefaultApp( PROTOCOL_PREFIX ); + + if ( defaultApp ) { + if ( ! override ) { + throw new LoggerError( + __( 'Protocol already exists. Use override option to replace existing handlers.' ) + ); + } + + const originalBundleId = await getBundleIdFromApp( defaultApp ); + if ( originalBundleId ) { + originalHandlerState = { + appPath: defaultApp, + bundleId: originalBundleId, + }; + } + } + + const platform = process.platform; + switch ( platform ) { + case 'darwin': + await registerProtocolMacOS(); + break; + case 'win32': + await registerProtocolWindows(); + break; + default: + await registerProtocolLinux(); + break; + } + } catch ( error ) { + if ( error instanceof LoggerError ) { + throw error; + } + throw new LoggerError( __( 'Failed to register protocol handler' ), error ); + } +} + +/** + * Extract bundle ID from an app bundle + * @param appPath - Path to the .app bundle + * @returns The CFBundleIdentifier or null if not found + */ +export async function getBundleIdFromApp( appPath: string ): Promise< string | null > { + try { + const plistPath = path.join( appPath, 'Contents', 'Info.plist' ); + if ( ! fs.existsSync( plistPath ) ) { + return null; + } + + const plistContent = fs.readFileSync( plistPath, 'utf-8' ); + const plistObj = plist.parse( plistContent ); + + // @ts-expect-error plist types are not complete. + return plistObj.CFBundleIdentifier || null; + } catch { + return null; + } +} + +/** + * Get the default app for a given protocol + * Returns the app path if one exists, null otherwise + */ +export async function getDefaultApp( protocol: string ): Promise< string | null > { + const platform = process.platform; + + try { + switch ( platform ) { + case 'darwin': + return await getDefaultAppMacOS( protocol ); + case 'win32': + return await getDefaultAppWindows( protocol ); + default: + return await getDefaultAppLinux( protocol ); + } + } catch { + return null; + } +} + +/** + * Unregister the CLI as the protocol handler and restore the original handler + * Should be called after successful authentication to clean up + */ +export async function unregisterProtocolHandler(): Promise< void > { + if ( protocolRegistrationPath && fs.existsSync( protocolRegistrationPath ) ) { + if ( protocolRegistrationPath.endsWith( '.app' ) ) { + await fs.promises.rm( protocolRegistrationPath, { recursive: true, force: true } ); + } else { + await fs.promises.unlink( protocolRegistrationPath ); + } + protocolRegistrationPath = null; + } + + if ( originalHandlerState ) { + try { + if ( ! fs.existsSync( originalHandlerState.appPath ) ) { + originalHandlerState = null; + return; + } + + const platform = process.platform; + switch ( platform ) { + case 'darwin': { + // Add the original handler back to Launch Services + const defaultsCommand = `defaults write com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers -array-add '{LSHandlerURLScheme = "${ PROTOCOL_PREFIX }"; LSHandlerRoleAll = "${ originalHandlerState.bundleId }";}'`; + await executeCommand( defaultsCommand ); + + // Re-register the original app + await executeCommand( `${ LSREGISTER_PATH } -f "${ originalHandlerState.appPath }"` ); + break; + } + case 'win32': + // Restore Windows registry entry + await executeCommand( + `reg add "HKEY_CURRENT_USER\\Software\\Classes\\${ PROTOCOL_PREFIX }\\shell\\open\\command" /ve /t REG_SZ /d "${ originalHandlerState.appPath } \\"%1\\"" /f` + ); + break; + default: + // For Linux, the original app should still be registered via .desktop file + // No additional action needed as we only remove our own .desktop file + break; + } + + originalHandlerState = null; + } catch { + originalHandlerState = null; + } + } +} + +/** + * Get the CLI executable command for protocol handling + */ +function getCLICommand(): string { + const nodePath = process.argv[ 0 ]; + const scriptPath = process.argv[ 1 ]; + + // Check if we're running via a global installation (npm, yarn, etc.) + if ( + scriptPath.includes( '/bin/studio' ) || + scriptPath.includes( '.npm' ) || + path.basename( scriptPath ) === 'studio' + ) { + // Global installation - use the CLI directly + return `"${ scriptPath }" auth callback`; + } + + // Check if we're running the built version + if ( scriptPath.includes( 'dist/cli/main.js' ) ) { + return `"${ nodePath }" "${ scriptPath }" auth callback`; + } + + // Development mode - try to find the built version + if ( scriptPath.includes( 'cli/index.ts' ) || scriptPath.includes( 'tsx' ) ) { + const bundledPath = path.resolve( __dirname, '../../dist/cli/main.js' ); + if ( fs.existsSync( bundledPath ) ) { + return `"${ nodePath }" "${ bundledPath }" auth callback`; + } + } + + // Fallback to current setup + return `"${ nodePath }" "${ scriptPath }" auth callback`; +} + +/** + * Execute shell command with promise and return output + */ +function executeCommand( command: string ): Promise< string > { + return new Promise( ( resolve, reject ) => { + const child = spawn( command, [], { shell: true } ); + let stdout = ''; + let stderr = ''; + + child.stdout?.on( 'data', ( data ) => { + stdout += data.toString(); + } ); + + child.stderr?.on( 'data', ( data ) => { + stderr += data.toString(); + } ); + + child.on( 'exit', ( code ) => { + if ( code === 0 ) { + resolve( stdout.trim() ); + } else { + reject( + new Error( `Command failed with exit code ${ code }: ${ command }. stderr: ${ stderr }` ) + ); + } + } ); + + child.on( 'error', ( error ) => { + reject( new Error( `Command failed: ${ command } - ${ error.message }` ) ); + } ); + } ); +} + +/** + * Get default app for protocol on macOS using AppleScript + */ +async function getDefaultAppMacOS( protocol: string ): Promise< string | null > { + try { + const appleScript = ` +use AppleScript version "2.4" +use framework "Foundation" +use framework "AppKit" + +set theWorkspace to current application's NSWorkspace's sharedWorkspace() +set defaultAppURL to theWorkspace's URLForApplicationToOpenURL:(current application's |NSURL|'s URLWithString:"${ protocol }://test") +if defaultAppURL = missing value then + return "pr-result=false" +else + return (the POSIX path of (defaultAppURL as «class furl»)) as text +end if`; + + const result = await executeCommand( + `osascript -e '${ appleScript.replace( /'/g, "'\"'\"'" ) }'` + ); + return result.trim() === 'pr-result=false' ? null : result.trim(); + } catch { + return null; + } +} + +async function registerProtocolMacOS(): Promise< void > { + const cliCommand = getCLICommand(); + const appName = 'StudioCLIAuth'; + + const appDir = path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Studio', + 'ProtocolHandler' + ); + const appPath = path.join( appDir, `${ appName }.app` ); + + protocolRegistrationPath = appPath; + + try { + if ( fs.existsSync( appPath ) ) { + await fs.promises.rm( appPath, { recursive: true, force: true } ); + } + + await fs.promises.mkdir( appDir, { recursive: true } ); + + const escapedCliCommand = cliCommand.replace( /"/g, '\\"' ); + + const appleScript = `on open location this_URL + set command to "${ escapedCliCommand } '" & this_URL & "'" + try + do shell script "export PATH='${ process.env.PATH }'; " & command + on error errMsg number errNum + display dialog "CLI Auth Error: " & errMsg & return & "Command: " & command buttons {"OK"} default button 1 + end try +end open location`; + + const applescriptPath = path.join( appDir, `url-${ PROTOCOL_PREFIX }.applescript` ); + await fs.promises.writeFile( applescriptPath, appleScript ); + + await executeCommand( `osacompile -o "${ appPath }" "${ applescriptPath }"` ); + await fs.promises.unlink( applescriptPath ); + + const plistPath = path.join( appPath, 'Contents', 'Info.plist' ); + const plistContent = fs.readFileSync( plistPath, 'utf-8' ); + const plistObj = plist.parse( plistContent ); + + // @ts-expect-error plist types are not complete. + plistObj.CFBundleIdentifier = `${ BUNDLE_ID_PREFIX }.${ PROTOCOL_PREFIX }`; + // @ts-expect-error plist types are not complete. + plistObj.CFBundleURLTypes = [ + { + CFBundleURLName: `URL : ${ PROTOCOL_PREFIX }`, + CFBundleURLSchemes: [ PROTOCOL_PREFIX ], + }, + ]; + + fs.writeFileSync( plistPath, plist.build( plistObj ) ); + + await executeCommand( `open -g -W "${ appPath }"` ); + + const bundleId = `${ BUNDLE_ID_PREFIX }.${ PROTOCOL_PREFIX }`; + const defaultsCommand = `defaults write com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers -array-add '{LSHandlerURLScheme = "${ PROTOCOL_PREFIX }"; LSHandlerRoleAll = "${ bundleId }";}'`; + + await executeCommand( defaultsCommand ); + + const lsregisterCommand = `${ LSREGISTER_PATH } -kill -r -domain local -domain system -domain user`; + await executeCommand( lsregisterCommand ); + + const reregisterCommand = `${ LSREGISTER_PATH } -f "${ appPath }"`; + await executeCommand( reregisterCommand ); + } catch ( error ) { + if ( protocolRegistrationPath && fs.existsSync( protocolRegistrationPath ) ) { + await fs.promises.rm( protocolRegistrationPath, { recursive: true, force: true } ); + protocolRegistrationPath = null; + } + throw error; + } +} + +async function getDefaultAppWindows( protocol: string ): Promise< string | null > { + try { + const result = await executeCommand( + `reg query "HKEY_CURRENT_USER\\Software\\Classes\\${ protocol }\\shell\\open\\command" /ve` + ); + return result.includes( 'REG_SZ' ) ? result : null; + } catch { + return null; + } +} + +async function registerProtocolWindows(): Promise< void > { + const cliCommand = getCLICommand(); + + await executeCommand( + `reg add "HKEY_CURRENT_USER\\Software\\Classes\\${ PROTOCOL_PREFIX }" /ve /t REG_SZ /d "URL:WordPress Studio Protocol" /f` + ); + await executeCommand( + `reg add "HKEY_CURRENT_USER\\Software\\Classes\\${ PROTOCOL_PREFIX }" /v "URL Protocol" /t REG_SZ /d "" /f` + ); + await executeCommand( + `reg add "HKEY_CURRENT_USER\\Software\\Classes\\${ PROTOCOL_PREFIX }\\shell\\open\\command" /ve /t REG_SZ /d "${ cliCommand } \\"%1\\"" /f` + ); +} + +async function getDefaultAppLinux( protocol: string ): Promise< string | null > { + try { + const result = await executeCommand( + `xdg-mime query default "x-scheme-handler/${ protocol }"` + ); + return result.trim() || null; + } catch { + return null; + } +} + +async function registerProtocolLinux(): Promise< void > { + const cliCommand = getCLICommand(); + const homeDir = os.homedir(); + const applicationsDir = path.join( homeDir, '.local', 'share', 'applications' ); + + await fs.promises.mkdir( applicationsDir, { recursive: true } ); + + const desktopContent = `[Desktop Entry] +Name=Studio CLI Auth Handler +Type=Application +Exec=${ cliCommand } %u +MimeType=x-scheme-handler/${ PROTOCOL_PREFIX } +NoDisplay=true +`; + + const desktopFileName = `studio-cli-auth-${ Date.now() }.desktop`; + const desktopFile = path.join( applicationsDir, desktopFileName ); + await fs.promises.writeFile( desktopFile, desktopContent ); + + protocolRegistrationPath = desktopFile; + + await executeCommand( + `xdg-mime default "${ desktopFileName }" "x-scheme-handler/${ PROTOCOL_PREFIX }"` + ); + await executeCommand( 'update-desktop-database ~/.local/share/applications' ); +} diff --git a/cli/lib/resources.ts b/cli/lib/resources.ts new file mode 100644 index 0000000000..56a00ff6f8 --- /dev/null +++ b/cli/lib/resources.ts @@ -0,0 +1,76 @@ +import path from 'path'; +import { pathExists } from 'common/lib/fs-utils'; +import { downloadFiles, getWordPressResourceFiles } from 'common/lib/resource-downloader'; +import { storagePaths } from 'cli/storage/paths'; + +/** + * Check if all required WordPress resources are available locally + */ +async function resourcesExist(): Promise< boolean > { + const serverFilesPath = storagePaths.getServerFilesPath(); + + const wordpressPath = path.join( serverFilesPath, 'latest', 'wordpress' ); + const sqlitePath = path.join( serverFilesPath, 'sqlite-database-integration' ); + const wpCliPath = path.join( serverFilesPath, 'wp-cli.phar' ); + + return ( + ( await pathExists( wordpressPath ) ) && + ( await pathExists( sqlitePath ) ) && + ( await pathExists( wpCliPath ) ) + ); +} + +/** + * Download all WordPress resources to server files directory + */ +async function downloadAllResources(): Promise< void > { + const serverFilesPath = storagePaths.getServerFilesPath(); + const files = getWordPressResourceFiles( serverFilesPath ); + + try { + await downloadFiles( files, serverFilesPath, { silent: true } ); + } catch ( error ) { + throw new Error( `Failed to download WordPress resources: ${ ( error as Error ).message }` ); + } +} + +/** + * Ensure WordPress resources are available for CLI operation + * This is a mandatory requirement - CLI will not work without resources + */ +export async function ensureResourcesAvailable(): Promise< void > { + if ( await resourcesExist() ) { + return; + } + + console.log( ` +🚀 Welcome to WordPress Studio CLI! + +Setting the CLI... +This is a one-time setup that downloads essential WordPress files: + +📦 WordPress (latest version) +🔧 SQLite Database Integration +⚡ WP-CLI tools + +Files will be cached locally for offline use. +This may take a minute... +` ); + + try { + await downloadAllResources(); + console.log( ` +✅ Setup complete! WordPress Studio CLI is ready to use. +` ); + } catch ( error ) { + console.error( ` +❌ Setup failed: Unable to download WordPress resources. + +WordPress Studio CLI requires internet connection for first-time setup. +Please check your connection and try again. + +Error: ${ ( error as Error ).message } +` ); + process.exit( 1 ); // Hard fail - CLI won't work without resources + } +} diff --git a/cli/lib/tests/validation.test.ts b/cli/lib/tests/validation.test.ts index 97369e4617..7cf87c6cf3 100644 --- a/cli/lib/tests/validation.test.ts +++ b/cli/lib/tests/validation.test.ts @@ -1,8 +1,7 @@ import fs from 'fs'; import path from 'path'; import { calculateDirectorySize, isWordPressDirectory } from 'common/lib/fs-utils'; -import { validateSiteFolder, validateSiteSize } from 'cli/lib/validation'; -import { LoggerError } from 'cli/logger'; +import { validateReadSitePath, validateSiteSize } from 'cli/lib/validation'; jest.mock( 'fs' ); jest.mock( 'path' ); @@ -18,56 +17,70 @@ describe( 'Validation Module', () => { jest.clearAllMocks(); ( path.join as jest.Mock ).mockImplementation( ( ...args ) => args.join( '/' ) ); ( fs.existsSync as jest.Mock ).mockReturnValue( true ); + ( fs.statSync as jest.Mock ).mockReturnValue( { isDirectory: () => true } ); ( isWordPressDirectory as jest.Mock ).mockReturnValue( true ); ( calculateDirectorySize as jest.Mock ).mockResolvedValue( 1024 * 1024 * 1024 ); // 1GB } ); - describe( 'validateSiteFolder', () => { - it( 'should throw LoggerError if site folder does not exist', () => { + describe( 'validateReadSitePath', () => { + it( 'should return invalid result if site folder does not exist', () => { ( fs.existsSync as jest.Mock ).mockReturnValue( false ); - expect( () => validateSiteFolder( mockSiteFolder ) ).toThrow( LoggerError ); - expect( () => validateSiteFolder( mockSiteFolder ) ).toThrow( - `Folder not found: ${ mockSiteFolder }` - ); + const result = validateReadSitePath( mockSiteFolder ); + expect( result.valid ).toBe( false ); + expect( result.error ).toContain( `Folder not found: ${ mockSiteFolder }` ); } ); - it( 'should return true for a valid WordPress directory', () => { + it( 'should return valid result for a valid WordPress directory', () => { ( fs.existsSync as jest.Mock ).mockReturnValue( true ); + ( fs.statSync as jest.Mock ).mockReturnValue( { isDirectory: () => true } ); ( isWordPressDirectory as jest.Mock ).mockReturnValue( true ); - const result = validateSiteFolder( mockSiteFolder ); - expect( result ).toBe( true ); + const result = validateReadSitePath( mockSiteFolder ); + expect( result.valid ).toBe( true ); + expect( result.error ).toBeUndefined(); expect( isWordPressDirectory ).toHaveBeenCalledWith( mockSiteFolder ); } ); - it( 'should throw LoggerError for an invalid WordPress directory', () => { + it( 'should return invalid result for an invalid WordPress directory', () => { ( fs.existsSync as jest.Mock ).mockReturnValue( true ); + ( fs.statSync as jest.Mock ).mockReturnValue( { isDirectory: () => true } ); ( isWordPressDirectory as jest.Mock ).mockReturnValue( false ); - expect( () => validateSiteFolder( mockSiteFolder ) ).toThrow( LoggerError ); - expect( () => validateSiteFolder( mockSiteFolder ) ).toThrow( - /Please ensure it contains a wp-content directory/ - ); + const result = validateReadSitePath( mockSiteFolder ); + expect( result.valid ).toBe( false ); + expect( result.error ).toContain( 'Please ensure it contains a wp-content directory' ); expect( isWordPressDirectory ).toHaveBeenCalledWith( mockSiteFolder ); } ); + + it( 'should return invalid result if path is not a directory', () => { + ( fs.existsSync as jest.Mock ).mockReturnValue( true ); + ( fs.statSync as jest.Mock ).mockReturnValue( { isDirectory: () => false } ); + + const result = validateReadSitePath( mockSiteFolder ); + expect( result.valid ).toBe( false ); + expect( result.error ).toBe( 'Path must be a directory' ); + } ); } ); describe( 'validateSiteSize', () => { - it( 'should throw an error if the site exceeds size limit', async () => { + it( 'should return invalid result if the site exceeds size limit', async () => { ( calculateDirectorySize as jest.Mock ).mockResolvedValue( 3 * 1024 * 1024 * 1024 ); // 3GB - await expect( validateSiteSize( mockSiteFolder ) ).rejects.toThrow( LoggerError ); - await expect( validateSiteSize( mockSiteFolder ) ).rejects.toThrow( + const result = await validateSiteSize( mockSiteFolder ); + expect( result.valid ).toBe( false ); + expect( result.error ).toContain( 'Your site exceeds the 2 GB size limit. Please, consider removing unnecessary media files, plugins, or themes from wp-content.' ); expect( calculateDirectorySize ).toHaveBeenCalledWith( mockSiteFolder + '/wp-content' ); } ); - it( 'should return true for a valid WordPress site within size limit', async () => { + it( 'should return valid result for a WordPress site within size limit', async () => { ( calculateDirectorySize as jest.Mock ).mockResolvedValue( 1024 * 1024 * 1024 ); // 1GB - expect( await validateSiteSize( mockSiteFolder ) ).toBe( true ); + const result = await validateSiteSize( mockSiteFolder ); + expect( result.valid ).toBe( true ); + expect( result.error ).toBeUndefined(); expect( calculateDirectorySize ).toHaveBeenCalledWith( mockSiteFolder + '/wp-content' ); } ); } ); diff --git a/cli/lib/token-waiter.ts b/cli/lib/token-waiter.ts new file mode 100644 index 0000000000..71651d8ef1 --- /dev/null +++ b/cli/lib/token-waiter.ts @@ -0,0 +1,151 @@ +import fs from 'fs'; +import { __ } from '@wordpress/i18n'; +import { validateAccessToken } from 'cli/lib/api'; +import { readAppdata } from 'cli/lib/appdata'; +import { LoggerError, Logger } from 'cli/logger'; + +export interface AuthToken { + accessToken: string; + expiresIn: number; + expirationTime: number; + id: number; + email: string; + displayName: string; +} + +/** + * Wait for authentication token to be saved to appdata (unified for both desktop app and CLI callback) + */ +export async function waitForAuthenticationToken( + initialTimestamp: number, + timeoutMs: number = 120000, + logger: Logger< string > +): Promise< AuthToken > { + const startTime = Date.now(); + const pollInterval = 1000; // Poll every second + + logger.reportStart( 'TOKEN_WAIT', __( `Waiting for authentication…` ) ); + + while ( Date.now() - startTime < timeoutMs ) { + try { + const userData: { + authToken?: AuthToken; + } = await readAppdata(); + + if ( userData.authToken?.accessToken ) { + // Check if this token was updated after we started waiting + if ( userData.authToken.expirationTime > initialTimestamp ) { + // Validate the token to make sure it's working + try { + await validateAccessToken( userData.authToken.accessToken ); + + logger.reportSuccess( __( `Authentication received successfully` ) ); + + return { + accessToken: userData.authToken.accessToken, + expiresIn: userData.authToken.expiresIn, + expirationTime: userData.authToken.expirationTime, + id: userData.authToken.id, + email: userData.authToken.email, + displayName: userData.authToken.displayName, + }; + } catch ( tokenError ) { + // Token is invalid, continue polling for a valid one + logger.reportError( + new LoggerError( __( 'Received invalid token, continuing to wait...' ) ) + ); + } + } + } + } catch ( error ) { + // Continue polling if there's an error reading appdata + // The token might be in the process of being written + } + + // Wait before next poll + await new Promise( ( resolve ) => setTimeout( resolve, pollInterval ) ); + + // Show progress every 10 seconds + const elapsed = Date.now() - startTime; + if ( elapsed % 10000 < pollInterval ) { + const remaining = Math.round( ( timeoutMs - elapsed ) / 1000 ); + if ( remaining > 0 ) { + logger.reportStart( 'TOKEN_WAIT', __( `Still waiting... (${ remaining }s remaining)` ) ); + } + } + } + + throw new LoggerError( + __( + `Timeout waiting for authentication. The protocol handler may not be responding or there may be an issue with the OAuth callback.` + ) + ); +} + +/** + * Get the current timestamp before starting authentication flow + * Used to determine if a token was updated during the auth process + */ +export function getAuthStartTimestamp(): number { + return Date.now(); +} + +/** + * Watch for file system changes to the appdata file (alternative approach) + * This is more efficient than polling but may not work on all platforms + */ +export async function waitForTokenWithFileWatcher( + appdataPath: string, + initialTimestamp: number, + timeoutMs: number = 60000, + logger: Logger< string > +): Promise< AuthToken > { + return new Promise( ( resolve, reject ) => { + const timeout = setTimeout( () => { + watcher.close(); + reject( new LoggerError( __( 'Timeout waiting for authentication from desktop app' ) ) ); + }, timeoutMs ); + + let watcher: fs.FSWatcher; + + const checkToken = async () => { + try { + const userData = await readAppdata(); + + if ( + userData.authToken?.accessToken && + userData.authToken.expirationTime > initialTimestamp + ) { + // Validate the token + await validateAccessToken( userData.authToken.accessToken ); + + clearTimeout( timeout ); + watcher.close(); + logger.reportSuccess( __( 'Authentication received from desktop app' ) ); + + resolve( { + accessToken: userData.authToken.accessToken, + expiresIn: userData.authToken.expiresIn, + expirationTime: userData.authToken.expirationTime, + id: userData.authToken.id, + email: userData.authToken.email, + displayName: userData.authToken.displayName, + } ); + } + } catch { + // Continue watching if validation fails + } + }; + + try { + watcher = fs.watch( appdataPath, { persistent: false }, async ( eventType ) => { + if ( eventType === 'change' ) { + await checkToken(); + } + } ); + } catch ( error ) { + clearTimeout( timeout ); + reject( new LoggerError( __( 'Failed to watch appdata file for changes' ), error ) ); + } + } ); +} diff --git a/cli/lib/validation.ts b/cli/lib/validation.ts index 2c5d9d73bd..00c53cfdc9 100644 --- a/cli/lib/validation.ts +++ b/cli/lib/validation.ts @@ -3,39 +3,92 @@ import path from 'path'; import { __, sprintf } from '@wordpress/i18n'; import { DEMO_SITE_SIZE_LIMIT_BYTES, DEMO_SITE_SIZE_LIMIT_GB } from 'common/constants'; import { calculateDirectorySize, isWordPressDirectory } from 'common/lib/fs-utils'; -import { LoggerError } from 'cli/logger'; -export function validateSiteFolder( siteFolder: string ): boolean { - if ( ! fs.existsSync( siteFolder ) ) { - throw new LoggerError( sprintf( __( 'Folder not found: %s' ), siteFolder ) ); +export interface ValidationResult { + valid: boolean; + error?: string; +} + +export function validateReadSitePath( + sitePath: string, + ignoreWordPressCheck: boolean = false +): ValidationResult { + if ( ! fs.existsSync( sitePath ) ) { + return { valid: false, error: sprintf( __( 'Folder not found: %s' ), sitePath ) }; + } + + const stat = fs.statSync( sitePath ); + if ( ! stat.isDirectory() ) { + return { valid: false, error: __( 'Path must be a directory' ) }; } - if ( ! isWordPressDirectory( siteFolder ) ) { - throw new LoggerError( - __( + if ( ! ignoreWordPressCheck && ! isWordPressDirectory( sitePath ) ) { + return { + valid: false, + error: __( `The specified folder doesn't appear to be a WordPress site. ` + `Please ensure it contains a wp-content directory.` - ) - ); + ), + }; } - return true; + return { valid: true }; +} + +export function validateCreateSitePath( sitePath: string ): ValidationResult { + const resolvedPath = path.resolve( sitePath ); + + if ( fs.existsSync( resolvedPath ) ) { + const stat = fs.statSync( resolvedPath ); + if ( ! stat.isDirectory() ) { + return { valid: false, error: __( 'Path must be a directory' ) }; + } + + const files = fs.readdirSync( resolvedPath ); + if ( files.length > 0 ) { + return { + valid: false, + error: __( 'Directory is not empty' ), + }; + } + return { valid: true }; + } else { + const parentDir = path.dirname( resolvedPath ); + if ( ! fs.existsSync( parentDir ) ) { + return { + valid: false, + error: sprintf( __( 'Parent directory does not exist: %s' ), parentDir ), + }; + } + + try { + fs.accessSync( parentDir, fs.constants.W_OK ); + } catch { + return { + valid: false, + error: sprintf( __( 'Cannot write to parent directory: %s' ), parentDir ), + }; + } + + return { valid: true }; + } } -export async function validateSiteSize( siteFolder: string ): Promise< true > { +export async function validateSiteSize( siteFolder: string ): Promise< ValidationResult > { const wpContentPath = path.join( siteFolder, 'wp-content' ); const wpContentSize = await calculateDirectorySize( wpContentPath ); if ( wpContentSize > DEMO_SITE_SIZE_LIMIT_BYTES ) { - throw new LoggerError( - sprintf( + return { + valid: false, + error: sprintf( __( 'Your site exceeds the %d GB size limit. Please, consider removing unnecessary media files, plugins, or themes from wp-content.' ), DEMO_SITE_SIZE_LIMIT_GB - ) - ); + ), + }; } - return true; + return { valid: true }; } diff --git a/cli/storage/paths.ts b/cli/storage/paths.ts new file mode 100644 index 0000000000..cfb9c050b4 --- /dev/null +++ b/cli/storage/paths.ts @@ -0,0 +1,15 @@ +import os from 'os'; +import path from 'path'; +import { __ } from '@wordpress/i18n'; +import { createStoragePaths } from 'common/lib/storage-paths'; +import { LoggerError } from 'cli/logger'; + +const appDataPath = + process.platform === 'win32' + ? process.env.APPDATA || + ( () => { + throw new LoggerError( __( 'Studio config file path not found.' ) ); + } )() + : path.join( os.homedir(), 'Library', 'Application Support' ); + +export const storagePaths = createStoragePaths( appDataPath, 'Studio' ); diff --git a/common/constants.ts b/common/constants.ts index 2d9340f665..de9f86338c 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -11,3 +11,8 @@ export const PROTOCOL_PREFIX = 'wpcom-local-dev'; export const LOCKFILE_NAME = 'appdata-v1.json.lock'; export const LOCKFILE_STALE_TIME = 5000; export const LOCKFILE_WAIT_TIME = 5000; + +// SQLite +export const SQLITE_FILENAME = 'sqlite-database-integration'; +export const SQLITE_DATABASE_INTEGRATION_VERSION = 'v2.2.6'; +export const SQLITE_DATABASE_INTEGRATION_RELEASE_URL = `https://github.com/WordPress/sqlite-database-integration/archive/refs/tags/${ SQLITE_DATABASE_INTEGRATION_VERSION }.zip`; diff --git a/common/lib/ai-assistant.ts b/common/lib/ai-assistant.ts new file mode 100644 index 0000000000..06c2227e2e --- /dev/null +++ b/common/lib/ai-assistant.ts @@ -0,0 +1,161 @@ +import WPCOM from 'wpcom'; +import { z } from 'zod'; +import { SiteDetails } from 'common/types/sites'; + +export interface AIContext { + current_url?: string; + number_of_sites?: number; + wp_version?: string; + php_version?: string; + plugins?: string[]; + themes?: string[]; + current_theme?: string; + is_block_theme?: boolean; + ide?: string[]; + site_name?: string; + os?: string; +} + +export interface AIMessage { + content: string; + role: 'user' | 'assistant'; +} + +export interface AIQuota { + max_quota: number; + remaining_quota: number; + quota_reset_date: string; + userCanSendMessage: boolean; + daysUntilReset: number; +} + +const assistantResponseSchema = z.object( { + choices: z.array( + z.object( { + index: z.number(), + message: z.object( { + content: z.string(), + id: z.number(), + role: z.string(), + } ), + } ) + ), + created_at: z.string(), + id: z.number(), +} ); + +const assistantHeadersSchema = z.object( { + 'x-quota-max': z.coerce.number(), + 'x-quota-remaining': z.coerce.number(), + 'x-quota-reset': z.string().datetime( { offset: true } ), +} ); + +export interface AIResponse { + content: string; + messageId: number; + chatId: number; + quota: AIQuota; +} + +export class AIAssistantError extends Error { + constructor( + message: string, + public cause?: unknown + ) { + super( message ); + this.name = 'AIAssistantError'; + } +} + +/** + * Ask a question to the AI assistant + */ +export async function askAI( + question: string, + context: AIContext, + accessToken: string, + chatId?: number +): Promise< AIResponse > { + const client = new WPCOM( accessToken ); + + const messages: AIMessage[] = [ + { + content: question, + role: 'user', + }, + ]; + + try { + const { data, headers } = await new Promise< { + data: z.infer< typeof assistantResponseSchema >; + headers: z.infer< typeof assistantHeadersSchema >; + } >( ( resolve, reject ) => { + client.req.post( + { + path: '/studio-app/ai-assistant/chat', + apiNamespace: 'wpcom/v2', + body: { + messages, + chat_id: chatId, + context, + }, + }, + ( error, data, headers ) => { + if ( error ) { + return reject( new AIAssistantError( 'Failed to get AI response', error ) ); + } + + try { + const validatedData = assistantResponseSchema.parse( data ); + const validatedHeaders = assistantHeadersSchema.parse( headers ); + return resolve( { data: validatedData, headers: validatedHeaders } ); + } catch ( validationError ) { + return reject( new AIAssistantError( 'Invalid API response format', validationError ) ); + } + } + ); + } ); + + // Calculate days until reset + const resetDate = new Date( headers[ 'x-quota-reset' ] ); + const now = new Date(); + const daysUntilReset = Math.ceil( + ( resetDate.getTime() - now.getTime() ) / ( 1000 * 60 * 60 * 24 ) + ); + + const quota: AIQuota = { + max_quota: headers[ 'x-quota-max' ], + remaining_quota: headers[ 'x-quota-remaining' ], + quota_reset_date: headers[ 'x-quota-reset' ], + userCanSendMessage: headers[ 'x-quota-remaining' ] > 0, + daysUntilReset: Math.max( 0, daysUntilReset ), + }; + + return { + content: data.choices[ 0 ].message.content, + messageId: data.choices[ 0 ].message.id, + chatId: data.id, + quota, + }; + } catch ( error ) { + if ( error instanceof AIAssistantError ) { + throw error; + } + throw new AIAssistantError( 'Unexpected error occurred', error ); + } +} + +/** + * Get basic context information for AI assistant + */ +export function getBasicContext( siteDetails: SiteDetails ): AIContext { + const context: AIContext = { + site_name: siteDetails.name, + os: process.platform, + number_of_sites: 1, + php_version: siteDetails.phpVersion, + current_url: siteDetails.url || `http://localhost:${ siteDetails.port || '8080' }`, + }; + + return context; +} diff --git a/src/lib/download.ts b/common/lib/download.ts similarity index 100% rename from src/lib/download.ts rename to common/lib/download.ts diff --git a/common/lib/fs-utils.ts b/common/lib/fs-utils.ts index 896c22fb57..3d4600c8f7 100644 --- a/common/lib/fs-utils.ts +++ b/common/lib/fs-utils.ts @@ -1,5 +1,6 @@ -import fs from 'fs'; +import fs, { promises as fsPromises } from 'fs'; import path from 'path'; +import { isErrnoException } from './is-errno-exception'; /** * Calculates the total size of a directory by recursively traversing its contents. @@ -13,7 +14,7 @@ export function calculateDirectorySize( directoryPath: string ): Promise< number async function calculateSize( dirPath: string ): Promise< void > { try { - const files = await fs.promises.readdir( dirPath, { withFileTypes: true } ); + const files = await fsPromises.readdir( dirPath, { withFileTypes: true } ); await Promise.all( files.map( async ( file ) => { @@ -22,7 +23,7 @@ export function calculateDirectorySize( directoryPath: string ): Promise< number if ( file.isDirectory() ) { await calculateSize( filePath ); } else { - const stats = await fs.promises.stat( filePath ); + const stats = await fsPromises.stat( filePath ); totalSize += stats.size; } } catch ( error ) { @@ -63,3 +64,44 @@ export function arePathsEqual( path1: string, path2: string ) { return false; } } + +export async function pathExists( path: string ): Promise< boolean > { + try { + await fsPromises.access( path ); + return true; + } catch ( err: unknown ) { + if ( isErrnoException( err ) && err.code === 'ENOENT' ) { + return false; + } + throw err; + } +} + +export async function recursiveCopyDirectory( + source: string, + destination: string +): Promise< void > { + await fsPromises.mkdir( destination, { recursive: true } ); + + const entries = await fsPromises.readdir( source, { withFileTypes: true } ); + + for ( const entry of entries ) { + const sourcePath = path.join( source, entry.name ); + const destinationPath = path.join( destination, entry.name ); + + if ( entry.isDirectory() ) { + await recursiveCopyDirectory( sourcePath, destinationPath ); + } else if ( entry.isFile() ) { + await fsPromises.copyFile( sourcePath, destinationPath ); + } + } +} + +export async function isEmptyDir( directory: string ): Promise< boolean > { + const stats = await fsPromises.stat( directory ); + if ( ! stats.isDirectory() ) { + return false; + } + const files = await fsPromises.readdir( directory ); + return files.length === 0; +} diff --git a/common/lib/is-errno-exception.ts b/common/lib/is-errno-exception.ts new file mode 100644 index 0000000000..db7f81977d --- /dev/null +++ b/common/lib/is-errno-exception.ts @@ -0,0 +1,17 @@ +export function isErrnoException( value: unknown ): value is NodeJS.ErrnoException { + return ( + value instanceof Error && + ( ! ( 'errno' in value ) || + typeof value[ 'errno' ] === 'number' || + typeof value[ 'errno' ] === 'undefined' ) && + ( ! ( 'code' in value ) || + typeof value[ 'code' ] === 'string' || + typeof value[ 'code' ] === 'undefined' ) && + ( ! ( 'path' in value ) || + typeof value[ 'path' ] === 'string' || + typeof value[ 'path' ] === 'undefined' ) && + ( ! ( 'syscall' in value ) || + typeof value[ 'syscall' ] === 'string' || + typeof value[ 'syscall' ] === 'undefined' ) + ); +} diff --git a/common/lib/network-utils.ts b/common/lib/network-utils.ts new file mode 100644 index 0000000000..615e9c5925 --- /dev/null +++ b/common/lib/network-utils.ts @@ -0,0 +1,20 @@ +import dns from 'dns/promises'; + +/** + * Check if the system has internet connectivity by testing DNS resolution. + * + * @returns Promise that resolves to true if online, false if offline + */ +export async function isOnline(): Promise< boolean > { + try { + const timeoutPromise = new Promise( ( _, reject ) => + setTimeout( () => reject( new Error( 'Timeout' ) ), 5000 ) + ); + + await Promise.race( [ dns.resolve( 'google.com' ), timeoutPromise ] ); + + return true; + } catch { + return false; + } +} diff --git a/src/lib/port-finder.ts b/common/lib/port-finder.ts similarity index 100% rename from src/lib/port-finder.ts rename to common/lib/port-finder.ts diff --git a/common/lib/resource-downloader.ts b/common/lib/resource-downloader.ts new file mode 100644 index 0000000000..b2cdfcfef4 --- /dev/null +++ b/common/lib/resource-downloader.ts @@ -0,0 +1,135 @@ +import os from 'os'; +import path from 'path'; +import extract from 'extract-zip'; +import fs from 'fs-extra'; +import { download } from 'common/lib/download'; +import { SQLITE_DATABASE_INTEGRATION_RELEASE_URL } from '../constants'; + +export interface FileToDownload { + name: string; + description: string; + url: string | ( () => Promise< string > ); + destinationPath?: string; +} + +/** + * Download and extract a file to the specified destination + * @param file File configuration to download + * @param basePath Base path for relative destinations + * @param options Download options + */ +export async function downloadFile( + file: FileToDownload, + basePath: string, + options: { silent?: boolean } = {} +): Promise< void > { + const { name, description, destinationPath } = file; + const url = await getUrl( file.url ); + if ( ! options.silent ) { + console.log( `[${ name }] Downloading ${ description }...` ); + } + + const zipPath = path.join( os.tmpdir(), `${ name }.zip` ); + const extractedPath = destinationPath ?? basePath; + + try { + fs.ensureDirSync( extractedPath ); + } catch ( err ) { + const fsError = err as { code: string }; + if ( fsError.code !== 'EEXIST' ) throw err; + } + + // Import download function dynamically to avoid circular imports + await download( url, zipPath, true, name ); + + if ( name === 'wp-cli' ) { + if ( ! options.silent ) { + console.log( `[${ name }] Moving WP-CLI to destination...` ); + } + fs.moveSync( zipPath, path.join( extractedPath, 'wp-cli.phar' ), { overwrite: true } ); + } else if ( name === 'sqlite' ) { + /** + * The SQLite database integration plugin is extracted + * into a folder with the version number like sqlite-database-integration-1.0.0 + * We need to move the contents of that folder to the sqlite-database-integration folder + */ + await extract( zipPath, { dir: extractedPath } ); + + const files = fs.readdirSync( extractedPath ); + const sqliteFolder = files.find( ( file ) => + file.startsWith( 'sqlite-database-integration-' ) + ); + + if ( sqliteFolder ) { + const sourcePath = path.join( extractedPath, sqliteFolder ); + const targetPath = path.join( extractedPath, 'sqlite-database-integration' ); + if ( fs.existsSync( targetPath ) ) { + fs.rmSync( targetPath, { recursive: true, force: true } ); + } + fs.renameSync( sourcePath, targetPath ); + } + } else { + if ( ! options.silent ) { + console.log( `[${ name }] Extracting files from zip...` ); + } + await extract( zipPath, { dir: extractedPath } ); + } + if ( ! options.silent ) { + console.log( `[${ name }] Files extracted` ); + } +} + +/** + * Download multiple files sequentially + * @param files Array of files to download + * @param basePath Base path for relative destinations + * @param options Download options + */ +export async function downloadFiles( + files: FileToDownload[], + basePath: string, + options: { silent?: boolean } = {} +): Promise< void > { + for ( const file of files ) { + await downloadFile( file, basePath, options ); + } +} + +async function getUrl( url: string | ( () => Promise< string > ) ): Promise< string > { + return typeof url === 'function' ? await url() : url; +} + +/** + * Get the standard WordPress resource files configuration + * @param basePath Base path where files should be downloaded + */ +export function getWordPressResourceFiles( basePath: string ): FileToDownload[] { + return [ + { + name: 'wordpress', + description: 'WordPress (latest version)', + url: 'https://wordpress.org/latest.zip', + destinationPath: path.join( basePath, 'latest' ), + }, + { + name: 'sqlite', + description: 'SQLite Database Integration', + url: SQLITE_DATABASE_INTEGRATION_RELEASE_URL, + }, + { + name: 'wp-cli', + description: 'WP-CLI tools', + url: 'https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar', + }, + { + name: 'sqlite-command', + description: 'SQLite command tools', + url: async () => { + const { getLatestSQLiteCommandRelease } = await import( 'src/lib/sqlite-command-release' ); + const latestRelease = await getLatestSQLiteCommandRelease(); + return latestRelease.assets?.[ 0 ].browser_download_url ?? ''; + }, + destinationPath: path.join( basePath, 'sqlite-command' ), + }, + ]; +} diff --git a/common/lib/site-server.ts b/common/lib/site-server.ts new file mode 100644 index 0000000000..b1587bb15c --- /dev/null +++ b/common/lib/site-server.ts @@ -0,0 +1,205 @@ +import { spawn } from 'child_process'; +import http from 'http'; +import { SiteDetails } from 'common/types/sites'; + +interface StartSiteResult { + pid: number; +} + +// Health check configuration constants +const HEALTH_CHECK_TIMEOUT = 65000; // 65 seconds total timeout +const INITIAL_STARTUP_DELAY = 5000; // 5 seconds initial wait +const HEALTH_CHECK_INTERVAL = 1000; // Check every 1 second +const HTTP_REQUEST_TIMEOUT = 2000; // 2 seconds per HTTP request + +/** + * Check if a process with given PID is still running + * @param pid Process ID to check + * @returns true if process is running, false otherwise + */ +function isProcessRunning( pid: number ): boolean { + try { + // Sending signal 0 to a process doesn't kill it, just checks if it exists + process.kill( pid, 0 ); + return true; + } catch ( error ) { + return false; + } +} + +/** + * Performs a single HTTP health check to determine if WordPress is responding + * @param port Port number to check + * @returns Promise that resolves if server is healthy, rejects if not ready + */ +function performHealthCheck( port: number ): Promise< void > { + return new Promise( ( resolve, reject ) => { + const req = http.request( + { + hostname: 'localhost', + port, + method: 'HEAD', + timeout: HTTP_REQUEST_TIMEOUT, + }, + ( res ) => { + // Server is responding, WordPress is ready + // Accept success responses and redirects (common for WordPress) + if ( + res.statusCode && + ( res.statusCode < 400 || res.statusCode === 302 || res.statusCode === 301 ) + ) { + resolve(); + } else { + reject( new Error( `Unexpected HTTP status: ${ res.statusCode }` ) ); + } + } + ); + + req.on( 'error', reject ); + req.on( 'timeout', () => { + req.destroy(); + reject( new Error( 'HTTP request timeout' ) ); + } ); + + req.end(); + } ); +} + +/** + * Start a WordPress site using Playground CLI as a child process + * + * Spawns a detached WordPress Playground server process and waits for it to be ready + * using HTTP health checks. The process runs independently after this function returns. + * + * @param siteDetails Site configuration including path, port, and PHP version + * @returns Promise resolving to an object containing the process PID + * @throws Error if site is already running, port is undefined, or startup fails + */ +export async function startSite( siteDetails: SiteDetails ): Promise< StartSiteResult > { + if ( siteDetails.running ) { + throw new Error( 'Site is already running' ); + } + + if ( ! siteDetails.port ) { + throw new Error( 'Site port is not defined' ); + } + const port: number = siteDetails.port; + + // Use npx to run @wp-playground/cli - this will find the local package first + const args = [ + '@wp-playground/cli', + 'server', + '--skip-wordpress-setup', + '--port', + port.toString(), + '--login', + '--mount-before-install', + `${ siteDetails.path }:/wordpress`, + ]; + + if ( siteDetails.phpVersion ) { + args.push( '--php', siteDetails.phpVersion ); + } + + const childProcess = spawn( 'npx', args, { + detached: true, + stdio: 'ignore', + } ); + + const pid = childProcess.pid; + + if ( ! pid ) { + throw new Error( 'Failed to start playground server: no PID available' ); + } + + // Detach the process so it continues running independently + childProcess.unref(); + + // Wait for WordPress to be ready using health checks + await new Promise< void >( ( resolve, reject ) => { + let timeoutId: NodeJS.Timeout | null = null; + let intervalId: NodeJS.Timeout | null = null; + let isResolved = false; + + const cleanup = () => { + if ( timeoutId ) { + clearTimeout( timeoutId ); + timeoutId = null; + } + if ( intervalId ) { + clearInterval( intervalId ); + intervalId = null; + } + }; + + const resolveAndCleanup = ( result?: void ) => { + if ( isResolved ) return; + isResolved = true; + cleanup(); + resolve( result ); + }; + + const rejectAndCleanup = ( error: Error ) => { + if ( isResolved ) return; + isResolved = true; + cleanup(); + reject( error ); + }; + + // Set overall timeout + timeoutId = setTimeout( () => { + rejectAndCleanup( new Error( 'Timeout waiting for WordPress to be ready' ) ); + }, HEALTH_CHECK_TIMEOUT ); + + // Initial wait for process to start up, then begin health checks + setTimeout( () => { + if ( ! isProcessRunning( pid ) ) { + rejectAndCleanup( new Error( 'Playground server failed to start or crashed immediately' ) ); + return; + } + + // Start periodic health checks + intervalId = setInterval( () => { + if ( ! isProcessRunning( pid ) ) { + rejectAndCleanup( new Error( 'Playground server stopped before being ready' ) ); + return; + } + + performHealthCheck( port ) + .then( () => { + resolveAndCleanup(); // Server is ready! + } ) + .catch( () => { + // Server not ready yet, will try again on next interval + // No need to log - this is expected during startup + } ); + }, HEALTH_CHECK_INTERVAL ); + }, INITIAL_STARTUP_DELAY ); + } ); + + return { pid }; +} + +/** + * Stop a WordPress site by terminating its process + * + * Sends a SIGTERM signal to the site's process if a PID is available. + * If no PID is available but the site is marked as running, it may have + * been started by the Studio app. + * + * @param siteDetails Site details containing the PID to terminate + * @throws Error if site is running but no PID is available + */ +export async function stopSite( siteDetails: SiteDetails ): Promise< void > { + if ( siteDetails.pid ) { + try { + process.kill( siteDetails.pid, 'SIGTERM' ); + } catch ( error ) { + // Process already dead, continue + } + } else if ( siteDetails.running ) { + throw new Error( + 'Cannot stop site: no PID available for running site. This site may have been launched with the Studio App.' + ); + } +} diff --git a/common/lib/sqlite-setup.ts b/common/lib/sqlite-setup.ts new file mode 100644 index 0000000000..e9a7b12def --- /dev/null +++ b/common/lib/sqlite-setup.ts @@ -0,0 +1,54 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { SQLITE_FILENAME } from '../constants'; + +/** + * Removes legacy `sqlite-integration-plugin` installations from the specified + * installation path that including a `-main` branch suffix. + * + * @param installPath The path where the plugin is installed. + * + * @returns A promise that resolves when the plugin is successfully removed. + * + * @todo Remove this function after a few releases. + */ +export async function removeLegacySqliteIntegrationPlugin( installPath: string ) { + try { + const legacySqlitePluginPath = `${ installPath }-main`; + if ( await fs.pathExists( legacySqlitePluginPath ) ) { + await fs.remove( legacySqlitePluginPath ); + } + } catch ( error ) { + // If the removal fails, log the error but don't throw + console.error( 'Failed to remove legacy SQLite integration plugin:', error ); + } +} + +/** + * Sets up the SQLite database integration in a WordPress site. This includes the + * must-use plugin and the database configuration file. + * + * @param sitePath Path of the site. + * @param serverFilesPath Path to server files containing SQLite integration. + */ +export async function setupSqliteDatabase( sitePath: string, serverFilesPath: string ) { + const wpContentPath = path.join( sitePath, 'wp-content' ); + const databasePath = path.join( wpContentPath, 'database' ); + + await fs.mkdir( databasePath, { recursive: true } ); + + const dbPhpPath = path.join( wpContentPath, 'db.php' ); + await fs.copyFile( path.join( serverFilesPath, SQLITE_FILENAME, 'db.copy' ), dbPhpPath ); + const dbCopyContent = ( await fs.readFile( dbPhpPath, 'utf8' ) ).toString(); + await fs.writeFile( + dbPhpPath, + dbCopyContent.replace( + "'{SQLITE_IMPLEMENTATION_FOLDER_PATH}'", + `realpath( __DIR__ . '/mu-plugins/${ SQLITE_FILENAME }' )` + ) + ); + const sqlitePluginPath = path.join( wpContentPath, 'mu-plugins', SQLITE_FILENAME ); + await fs.copy( path.join( serverFilesPath, SQLITE_FILENAME ), sqlitePluginPath ); + + await removeLegacySqliteIntegrationPlugin( sqlitePluginPath ); +} diff --git a/common/lib/storage-paths.ts b/common/lib/storage-paths.ts new file mode 100644 index 0000000000..1c68714c9b --- /dev/null +++ b/common/lib/storage-paths.ts @@ -0,0 +1,41 @@ +import path from 'path'; + +/** + * Core storage paths interface for both Electron app and CLI + */ +export interface StoragePaths { + /** + * Platform-specific base app data directory + * @returns ~/Library/Application Support (macOS) or %APPDATA% (Windows) + */ + getAppDataPath(): string; + + /** + * Studio-specific data directory + * @returns ~/Library/Application Support/Studio + */ + getStudioDataPath(): string; + + /** + * Server files directory for downloaded/cached components + * @returns ~/Library/Application Support/Studio/server-files + */ + getServerFilesPath(): string; +} + +/** + * Create storage paths abstraction for cross-platform usage + * + * @param appDataPath Platform-specific base app data path + * @param appName Application name (usually 'Studio') + * @returns StoragePaths interface + */ +export function createStoragePaths( appDataPath: string, appName: string ): StoragePaths { + const studioDataPath = path.join( appDataPath, appName ); + + return { + getAppDataPath: () => appDataPath, + getStudioDataPath: () => studioDataPath, + getServerFilesPath: () => path.join( studioDataPath, 'server-files' ), + }; +} diff --git a/common/lib/tests/sqlite-setup.test.ts b/common/lib/tests/sqlite-setup.test.ts new file mode 100644 index 0000000000..c6e0f2fdaf --- /dev/null +++ b/common/lib/tests/sqlite-setup.test.ts @@ -0,0 +1,57 @@ +import fs from 'fs-extra'; +import { SQLITE_FILENAME } from '../../constants'; +import { setupSqliteDatabase } from '../sqlite-setup'; + +jest.mock( 'fs-extra' ); + +const MOCK_SITE_PATH = '/mock-site-path'; +const MOCK_SERVER_FILES_PATH = '/mock-server-files'; + +type MockedFsExtra = jest.Mocked< typeof fs >; + +describe( 'setupSqliteDatabase', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should set up SQLite database integration', async () => { + ( fs as MockedFsExtra ).readFile.mockResolvedValue( + // @ts-expect-error -- MOCKED buffer value -- + "SQLIntegration path: '{SQLITE_IMPLEMENTATION_FOLDER_PATH}'" + ); + + await setupSqliteDatabase( MOCK_SITE_PATH, MOCK_SERVER_FILES_PATH ); + + // Should create database directory + expect( fs.mkdir ).toHaveBeenCalledWith( `${ MOCK_SITE_PATH }/wp-content/database`, { + recursive: true, + } ); + + // Should copy db.copy to db.php + expect( fs.copyFile ).toHaveBeenCalledWith( + `${ MOCK_SERVER_FILES_PATH }/${ SQLITE_FILENAME }/db.copy`, + `${ MOCK_SITE_PATH }/wp-content/db.php` + ); + + // Should update db.php with correct path + expect( fs.writeFile ).toHaveBeenCalledWith( + `${ MOCK_SITE_PATH }/wp-content/db.php`, + `SQLIntegration path: realpath( __DIR__ . '/mu-plugins/${ SQLITE_FILENAME }' )` + ); + + // Should copy SQLite plugin files + expect( fs.copy ).toHaveBeenCalledWith( + `${ MOCK_SERVER_FILES_PATH }/${ SQLITE_FILENAME }`, + `${ MOCK_SITE_PATH }/wp-content/mu-plugins/${ SQLITE_FILENAME }` + ); + } ); + + it( 'should handle errors gracefully', async () => { + const mockError = new Error( 'File system error' ); + ( fs as MockedFsExtra ).mkdir.mockRejectedValue( mockError ); + + await expect( setupSqliteDatabase( MOCK_SITE_PATH, MOCK_SERVER_FILES_PATH ) ).rejects.toThrow( + 'File system error' + ); + } ); +} ); diff --git a/src/lib/tests/wordpress-version-utils.test.ts b/common/lib/tests/wordpress-version-utils.test.ts similarity index 100% rename from src/lib/tests/wordpress-version-utils.test.ts rename to common/lib/tests/wordpress-version-utils.test.ts diff --git a/src/lib/wordpress-provider/constants.ts b/common/lib/wordpress-provider/constants.ts similarity index 100% rename from src/lib/wordpress-provider/constants.ts rename to common/lib/wordpress-provider/constants.ts diff --git a/common/lib/wordpress-setup.ts b/common/lib/wordpress-setup.ts new file mode 100644 index 0000000000..37e794d3c1 --- /dev/null +++ b/common/lib/wordpress-setup.ts @@ -0,0 +1,83 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { recursiveCopyDirectory, pathExists } from './fs-utils'; +import { isOnline } from './network-utils'; +import { setupSqliteDatabase } from './sqlite-setup'; +import { + getWordPressVersionPath, + isWordPressVersionCached, + downloadWordPressVersion, +} from './wordpress-version-manager'; +import { isValidWordPressVersion } from './wordpress-version-utils'; + +export interface WordPressSetupOptions { + /** Path to the site directory where WordPress should be installed */ + sitePath: string; + /** WordPress version to install ('latest' or specific version) */ + wpVersion?: string; + /** Path to server files directory containing WordPress resources */ + serverFilesPath: string; +} + +/** + * Set up WordPress files in a directory with SQLite integration + * + * This is the core WordPress setup function that: + * 1. Downloads/caches specific WordPress versions when online + * 2. Falls back to bundled latest version when offline + * 3. Copies WordPress files from appropriate source to the site directory + * 4. Installs SQLite database integration if no wp-config.php exists + * + * @param options WordPress setup configuration + * @returns Promise - true if setup completed successfully + */ +export async function setupWordPressSite( options: WordPressSetupOptions ): Promise< boolean > { + const { sitePath, wpVersion = 'latest', serverFilesPath } = options; + + try { + const isOnlineStatus = await isOnline(); + + if ( ! isValidWordPressVersion( wpVersion ) ) { + throw new Error( + `Invalid WordPress version '${ wpVersion }'. ` + + 'Please use "latest" or valid version like "6.2", "6.0.1", "6.2-beta1", or "6.2-RC1"' + ); + } + + if ( + isOnlineStatus && + wpVersion !== 'latest' && + ! ( await isWordPressVersionCached( wpVersion, serverFilesPath ) ) + ) { + console.log( `Downloading WordPress version ${ wpVersion }...` ); + await downloadWordPressVersion( wpVersion, serverFilesPath ); + } + const sourceWpPath = getWordPressVersionPath( wpVersion, serverFilesPath ); + if ( ! ( await pathExists( sourceWpPath ) ) ) { + throw new Error( + 'Cannot set up WordPress while offline. WordPress files not found in server files. ' + + 'Please connect to the internet to download WordPress resources.' + ); + } + + // Copy WordPress files from source to site directory + try { + await recursiveCopyDirectory( sourceWpPath, sitePath ); + } catch ( error ) { + throw new Error( + 'Failed to copy WordPress files for setup. Please check directory permissions.' + ); + } + + // Set up SQLite database integration if no wp-config.php exists + const wpConfigPath = path.join( sitePath, 'wp-config.php' ); + if ( ! ( await fs.pathExists( wpConfigPath ) ) ) { + await setupSqliteDatabase( sitePath, serverFilesPath ); + } + + return true; + } catch ( error ) { + console.error( 'Failed to setup WordPress site:', error ); + throw error; + } +} diff --git a/common/lib/wordpress-version-manager.ts b/common/lib/wordpress-version-manager.ts new file mode 100644 index 0000000000..1dcba70f59 --- /dev/null +++ b/common/lib/wordpress-version-manager.ts @@ -0,0 +1,199 @@ +import { IncomingMessage } from 'http'; +import os from 'os'; +import path from 'path'; +import followRedirects, { FollowResponse } from 'follow-redirects'; +import fs from 'fs-extra'; +import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; +import unzipper from 'unzipper'; +import { pathExists } from './fs-utils'; +import { getWordPressVersionUrl } from './wordpress-version-utils'; + +const { https } = followRedirects; + +/** + * WordPress Version Manager + * + * Handles downloading and caching of specific WordPress versions. + * Does NOT handle offline fallback logic - that should be handled by the caller. + */ + +/** + * HTTP GET with proxy support + */ +function httpsGet( url: string, callback: ( res: IncomingMessage & FollowResponse ) => void ) { + const proxy = + process.env.https_proxy || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.HTTP_PROXY; + + let agent: HttpsProxyAgent | HttpProxyAgent | undefined; + + if ( proxy ) { + const urlParts = new URL( url ); + const Agent = urlParts.protocol === 'https:' ? HttpsProxyAgent : HttpProxyAgent; + agent = new Agent( { proxy } ); + } + + https.get( url, { agent }, callback ); +} + +interface DownloadResult { + downloaded: boolean; + statusCode: number; +} + +/** + * Download and unzip a file from URL to destination folder + */ +async function downloadFileAndUnzip( { + url, + destinationFolder, + checkFinalPath, + itemName, + overwrite = false, +}: { + url: string; + destinationFolder: string; + checkFinalPath: string; + itemName: string; + overwrite?: boolean; +} ): Promise< DownloadResult > { + let statusCode: number = 0; + try { + if ( fs.existsSync( checkFinalPath ) && ! overwrite ) { + return { downloaded: false, statusCode: 0 }; + } + + fs.ensureDirSync( destinationFolder ); + const response = await new Promise< IncomingMessage >( ( resolve ) => + httpsGet( url, ( response ) => resolve( response ) ) + ); + + statusCode = response.statusCode ?? 0; + if ( response.statusCode !== 200 ) { + throw new Error( `Failed to download file (Status code ${ response.statusCode }).` ); + } + + const entryPromises: Promise< void >[] = []; + + await new Promise< void >( ( resolve, reject ) => { + const unzipStream = unzipper.Parse(); + + unzipStream.on( 'entry', ( entry ) => { + const entryPath = entry.path; + const fullPath = path.join( destinationFolder, entryPath ); + + if ( entry.type === 'Directory' ) { + entry.autodrain(); + return; + } + + const entryPromise = new Promise< void >( ( resolveEntry, rejectEntry ) => { + fs.ensureDirSync( path.dirname( fullPath ) ); + const writeStream = fs.createWriteStream( fullPath ); + + entry.pipe( writeStream ); + + writeStream.on( 'close', () => resolveEntry() ); + writeStream.on( 'error', ( error: unknown ) => rejectEntry( error ) ); + entry.on( 'error', ( error: unknown ) => rejectEntry( error ) ); + } ); + + entryPromises.push( entryPromise ); + } ); + + unzipStream.on( 'close', () => resolve() ); + unzipStream.on( 'error', ( error: unknown ) => reject( error ) ); + + response.pipe( unzipStream ); + } ); + + // Wait until all entries have been extracted before continuing + await Promise.all( entryPromises ); + + console.log( `Downloaded ${ itemName } to ${ checkFinalPath }` ); + return { downloaded: true, statusCode }; + } catch ( err ) { + console.error( `Error downloading or unzipping ${ itemName }:`, err ); + } + return { downloaded: false, statusCode }; +} + +/** + * Get the storage path for a specific WordPress version + * + * Special case: 'latest' version uses bundled location at server-files/latest/wordpress/ + * All other versions use server-files/wordpress-versions/{version}/ + */ +export function getWordPressVersionPath( wpVersion: string, serverFilesPath: string ): string { + if ( wpVersion === 'latest' ) { + // Special case: 'latest' version uses bundled location + return path.join( serverFilesPath, 'latest', 'wordpress' ); + } + // All other versions use the versioned structure + return path.join( serverFilesPath, 'wordpress-versions', wpVersion ); +} + +/** + * Check if a WordPress version is already cached locally + * + * For 'latest': checks bundled location at server-files/latest/wordpress/ + * For other versions: checks downloaded cache at server-files/wordpress-versions/{version}/ + */ +export async function isWordPressVersionCached( + wpVersion: string, + serverFilesPath: string +): Promise< boolean > { + const versionPath = getWordPressVersionPath( wpVersion, serverFilesPath ); + return await pathExists( versionPath ); +} + +/** + * Download and cache a specific WordPress version + * + * Note: 'latest' version is not downloaded as it's always bundled with the app + */ +export async function downloadWordPressVersion( + wpVersion: string, + serverFilesPath: string +): Promise< void > { + if ( wpVersion === 'latest' ) { + // 'latest' version is bundled, not downloaded - this shouldn't be called + throw new Error( 'Cannot download "latest" version - it is provided as bundled files' ); + } + const finalFolder = getWordPressVersionPath( wpVersion, serverFilesPath ); + const tempDir = await fs.mkdtemp( path.join( os.tmpdir(), 'wordpress-download-' ) ); + + try { + const { downloaded, statusCode } = await downloadFileAndUnzip( { + url: getWordPressVersionUrl( wpVersion ), + destinationFolder: tempDir, + checkFinalPath: finalFolder, + itemName: `WordPress ${ wpVersion }`, + overwrite: false, + } ); + + if ( ! downloaded ) { + throw new Error( + `Failed to download WordPress version ${ wpVersion }. Status code: ${ statusCode }` + ); + } + + // WordPress zip files contain a 'wordpress' folder, we want the contents + const wordpressFolder = path.join( tempDir, 'wordpress' ); + if ( await pathExists( wordpressFolder ) ) { + await fs.ensureDir( path.dirname( finalFolder ) ); + await fs.move( wordpressFolder, finalFolder, { overwrite: true } ); + } else { + throw new Error( + `Downloaded WordPress archive does not contain expected 'wordpress' folder` + ); + } + } finally { + // Clean up temp directory + await fs.remove( tempDir ).catch( () => { + // Ignore cleanup errors + } ); + } +} diff --git a/src/lib/wordpress-version-utils.ts b/common/lib/wordpress-version-utils.ts similarity index 100% rename from src/lib/wordpress-version-utils.ts rename to common/lib/wordpress-version-utils.ts diff --git a/common/lib/wp-org/version-groups.ts b/common/lib/wp-org/version-groups.ts new file mode 100644 index 0000000000..4dea9b88ea --- /dev/null +++ b/common/lib/wp-org/version-groups.ts @@ -0,0 +1,38 @@ +import { __ } from '@wordpress/i18n'; +import { type WordPressVersion } from './versions'; + +export interface WordPressVersionGroup { + label: string; + versions: WordPressVersion[]; + id: string; +} + +export function getGroupedWordPressVersions( + versions: WordPressVersion[] +): WordPressVersionGroup[] { + const latestVersion = versions.find( ( v ) => v.value === 'latest' ); + const betaVersions = versions.filter( ( v ) => v.isBeta || v.isDevelopment ); + const stableVersions = versions.filter( + ( v ) => ! v.isBeta && ! v.isDevelopment && v.value !== 'latest' + ); + + const groups: WordPressVersionGroup[] = [ + { + id: 'auto-updating', + label: __( 'Auto-updating' ), + versions: latestVersion ? [ { ...latestVersion, label: 'latest' } ] : [], + }, + { + id: 'beta-nightly', + label: __( 'Beta & Nightly' ), + versions: betaVersions, + }, + { + id: 'stable', + label: __( 'Stable Versions' ), + versions: stableVersions, + }, + ]; + + return groups; +} diff --git a/common/lib/wp-org/versions.ts b/common/lib/wp-org/versions.ts new file mode 100644 index 0000000000..a08cc965fd --- /dev/null +++ b/common/lib/wp-org/versions.ts @@ -0,0 +1,159 @@ +import { z } from 'zod'; + +// WordPress.org API constants +const MINIMUM_WORDPRESS_VERSION = '5.9.9'; +const WORDPRESS_API_BASE_URL = 'https://api.wordpress.org/core/version-check/1.7/'; + +// Schemas for WordPress.org API responses +const wordPressApiResponseSchema = z.object( { + offers: z.array( z.any() ), +} ); + +const wordPressOfferSchema = z.object( { + version: z.string(), + response: z.string(), +} ); + +// Types +type WordPressOffer = z.infer< typeof wordPressOfferSchema >; + +type WordPressApiOffer = { + version: string; + response?: string; + [ key: string ]: unknown; +}; + +type ProcessedOffer = { + version: string; + shortName: string; +}; + +export interface WordPressVersion { + isBeta: boolean; + isDevelopment: boolean; + label: string; + value: string; +} + +// WordPress version utility functions +function isWordPressDevVersion( version: string ): boolean { + // Match nightly build patterns that end with a build number + // Examples: 6.8-alpha1-12345, 6.8-beta2-59979, 6.8-dev-12345, 6.8-59979 + return /^\d+\.\d+(?:\.\d+)?(?:-[a-zA-Z0-9]+)*-\d+$/.test( version ); +} + +function isWordPressBetaVersion( version: string ): boolean { + return version.includes( 'beta' ) || version.includes( 'RC' ); +} + +// Core API fetching functions +async function fetchWordPressApiData( channel: 'beta' | 'development', version?: string ) { + const params = new URLSearchParams( { channel } ); + + if ( channel === 'beta' && version ) { + params.append( 'version', version ); + } + + const response = await fetch( `${ WORDPRESS_API_BASE_URL }?${ params }` ); + if ( ! response.ok ) { + throw new Error( 'Failed to fetch WordPress versions' ); + } + return wordPressApiResponseSchema.parse( await response.json() ); +} + +function findLatestStable( versions: ProcessedOffer[] ): ProcessedOffer | undefined { + return versions.find( + ( version: ProcessedOffer ) => + ! isWordPressBetaVersion( version.version ) && ! isWordPressDevVersion( version.version ) + ); +} + +function processWordPressOffers( + offers: WordPressApiOffer[], + isDevelopment = false, + shortNameOccurrences: Map< string, number > +): ProcessedOffer[] { + // We extract the shortName (major.minor) for each version to later calculate duplicates. e.g. 6.4.1 -> 6.4 + const extractShortName = ( version: string ): string => { + if ( isWordPressDevVersion( version ) ) { + return 'nightly'; + } + const match = version.match( /^(\d+\.\d+)/ ); + return match ? match[ 1 ] : version; + }; + + return offers + .map( ( offer ) => wordPressOfferSchema.safeParse( offer ) ) + .filter( ( result ): result is { success: true; data: WordPressOffer } => result.success ) + .filter( ( result ) => + isDevelopment ? result.data.response === 'development' : result.data.response === 'autoupdate' + ) + .map( ( { data } ) => { + const shortName = extractShortName( data.version ); + shortNameOccurrences.set( shortName, ( shortNameOccurrences.get( shortName ) || 0 ) + 1 ); + return { + version: data.version, + shortName, + }; + } ); +} + +function generateVersionLabel( + version: string, + shortName: string, + shortNameOccurrences: number +): string { + if ( isWordPressDevVersion( version ) ) { + return 'nightly'; + } + + // If is beta or there are two or more versions with the same major.minor versions, we show the full version. + // 6.4.1 and 6.4.2 will have the same shortName (6.4), so we'll show the full version. + if ( shortNameOccurrences > 1 || isWordPressBetaVersion( version ) ) { + return version; + } + return shortName; +} + +// Main function to fetch and process WordPress versions +export async function fetchWordPressVersions(): Promise< WordPressVersion[] > { + const [ stableData, developmentData ] = await Promise.all( [ + fetchWordPressApiData( 'beta', MINIMUM_WORDPRESS_VERSION ), + fetchWordPressApiData( 'development' ), + ] ); + + const shortNameOccurrences = new Map< string, number >(); + + const stableOffers = processWordPressOffers( stableData.offers, false, shortNameOccurrences ); + + const developmentOffer = processWordPressOffers( + developmentData.offers, + true, + shortNameOccurrences + )[ 0 ]; + + const allOffers = developmentOffer ? [ developmentOffer, ...stableOffers ] : stableOffers; + const latestStable = findLatestStable( allOffers ); + + const versionsList = allOffers.map( ( { version, shortName } ) => ( { + isBeta: isWordPressBetaVersion( version ), + isDevelopment: isWordPressDevVersion( version ), + label: generateVersionLabel( version, shortName, shortNameOccurrences.get( shortName ) || 0 ), + value: version, + } ) ); + + if ( latestStable ) { + versionsList.unshift( { + isBeta: false, + isDevelopment: false, + label: generateVersionLabel( + latestStable.version, + latestStable.shortName, + shortNameOccurrences.get( latestStable.shortName ) || 0 + ), + value: 'latest', + } ); + } + + return versionsList; +} diff --git a/common/types/sites.ts b/common/types/sites.ts new file mode 100644 index 0000000000..f69b92cdd1 --- /dev/null +++ b/common/types/sites.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const siteSchema = z + .object( { + id: z.string(), + path: z.string(), + name: z.string(), + port: z.number().optional(), + running: z.boolean().optional(), + phpVersion: z.string().optional(), + pid: z.number().optional(), + url: z.string().optional(), + customDomain: z.string().optional(), + enableHttps: z.boolean().optional(), + isWpAutoUpdating: z.boolean().optional(), + adminPassword: z.string().optional(), + } ) + .passthrough(); + +export type SiteDetails = z.infer< typeof siteSchema >; diff --git a/e2e/sites.test.ts b/e2e/sites.test.ts index 5f08e4f181..006bd66eb4 100644 --- a/e2e/sites.test.ts +++ b/e2e/sites.test.ts @@ -1,7 +1,7 @@ import http from 'http'; import path from 'path'; import { test, expect } from '@playwright/test'; -import { pathExists } from '../src/lib/fs-utils'; +import { pathExists } from '../common/lib/fs-utils'; import { E2ESession } from './e2e-helpers'; import MainSidebar from './page-objects/main-sidebar'; import Onboarding from './page-objects/onboarding'; diff --git a/forge.config.ts b/forge.config.ts index 511647a7be..523a69f61e 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -14,7 +14,7 @@ import { WebpackPlugin } from '@electron-forge/plugin-webpack'; import ForgeExternalsPlugin from '@timfish/forge-externals-plugin'; import ejs from 'ejs'; import { webpack } from 'webpack'; -import { isErrnoException } from './src/lib/is-errno-exception'; +import { isErrnoException } from './common/lib/is-errno-exception'; import cliConfig from './webpack.cli.config'; import mainConfig, { mainBaseConfig } from './webpack.main.config'; import { rendererConfig } from './webpack.renderer.config'; diff --git a/package-lock.json b/package-lock.json index 2750315560..ac26826e4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@automattic/interpolate-components": "^1.2.1", "@formatjs/intl-locale": "^3.4.5", "@formatjs/intl-localematcher": "^0.5.4", + "@inquirer/prompts": "^7.8.4", "@php-wasm/node": "^2.0.15", "@php-wasm/scopes": "^2.0.15", "@php-wasm/universal": "^2.0.15", @@ -60,6 +61,7 @@ "shell-quote": "^1.8.1", "strip-ansi": "^7.1.0", "tar": "^7.4.0", + "trash": "^9.0.0", "unzipper": "0.10.11", "url-loader": "^4.1.1", "winreg": "1.2.4", @@ -92,10 +94,12 @@ "@types/lockfile": "^1.0.4", "@types/lodash": "^4.17.20", "@types/node-fetch": "^2.6.12", + "@types/plist": "^3.0.5", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", + "@types/unzipper": "^0.10.11", "@types/winreg": "^1.2.36", "@types/yauzl": "^2.10.3", "@typescript-eslint/eslint-plugin": "^8.39.1", @@ -4053,6 +4057,381 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@inquirer/checkbox": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.2.2.tgz", + "integrity": "sha512-E+KExNurKcUJJdxmjglTl141EwxWyAHplvsYJQgSwXf8qiNWkTxTuCCqmhFEmbIXd4zLaGMfQFJ6WrZ7fSeV3g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.0", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.16.tgz", + "integrity": "sha512-j1a5VstaK5KQy8Mu8cHmuQvN1Zc62TbLhjJxwHvKPPKEoowSF6h/0UdOpA9DNdWZ+9Inq73+puRq1df6OJ8Sag==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.0", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.0.tgz", + "integrity": "sha512-NyDSjPqhSvpZEMZrLCYUquWNl+XC/moEcVFqS55IEYIYsY0a1cUCevSqk7ctOlnm/RaSBU5psFryNlxcmGrjaA==", + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/core/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.18", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.18.tgz", + "integrity": "sha512-yeQN3AXjCm7+Hmq5L6Dm2wEDeBRdAZuyZ4I7tWSSanbxDzqM0KqzoDbKM7p4ebllAYdoQuPJS6N71/3L281i6w==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.0", + "@inquirer/external-editor": "^1.0.1", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.18.tgz", + "integrity": "sha512-xUjteYtavH7HwDMzq4Cn2X4Qsh5NozoDHCJTdoXg9HfZ4w3R6mxV1B9tL7DGJX2eq/zqtsFjhm0/RJIMGlh3ag==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.0", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.1.tgz", + "integrity": "sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.6.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", + "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.2.2.tgz", + "integrity": "sha512-hqOvBZj/MhQCpHUuD3MVq18SSoDNHy7wEnQ8mtvs71K8OPZVXJinOzcvQna33dNYLYE4LkA9BlhAhK6MJcsVbw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.0", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.18.tgz", + "integrity": "sha512-7exgBm52WXZRczsydCVftozFTrrwbG5ySE0GqUd2zLNSBXyIucs2Wnm7ZKLe/aUu6NUg9dg7Q80QIHCdZJiY4A==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.0", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.18.tgz", + "integrity": "sha512-zXvzAGxPQTNk/SbT3carAD4Iqi6A2JS2qtcqQjsL22uvD+JfQzUrDEtPjLL7PLn8zlSNyPdY02IiQjzoL9TStA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.0", + "@inquirer/type": "^3.0.8", + "ansi-escapes": "^4.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.4.tgz", + "integrity": "sha512-MuxVZ1en1g5oGamXV3DWP89GEkdD54alcfhHd7InUW5BifAdKQEK9SLFa/5hlWbvuhMPlobF0WAx7Okq988Jxg==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.2.2", + "@inquirer/confirm": "^5.1.16", + "@inquirer/editor": "^4.2.18", + "@inquirer/expand": "^4.0.18", + "@inquirer/input": "^4.2.2", + "@inquirer/number": "^3.0.18", + "@inquirer/password": "^4.0.18", + "@inquirer/rawlist": "^4.1.6", + "@inquirer/search": "^3.1.1", + "@inquirer/select": "^4.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.6.tgz", + "integrity": "sha512-KOZqa3QNr3f0pMnufzL7K+nweFFCCBs6LCXZzXDrVGTyssjLeudn5ySktZYv1XiSqobyHRYYK0c6QsOxJEhXKA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.0", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.1.1.tgz", + "integrity": "sha512-TkMUY+A2p2EYVY3GCTItYGvqT6LiLzHBnqsU1rJbrpXUijFfM6zvUx0R4civofVwFCmJZcKqOVwwWAjplKkhxA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.0", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.3.2.tgz", + "integrity": "sha512-nwous24r31M+WyDEHV+qckXkepvihxhnyIaod2MG7eCE6G0Zm/HUF6jgN8GXgf4U7AU6SLseKdanY195cwvU6w==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.0", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", + "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -6905,36 +7284,128 @@ "integrity": "sha512-AFRnGNUnlIvq3M+ADdfWb+DIXWKK6yYEkVPAyOppkjO+cL/19gjXMdvAwv+CMFts28YCFKF8Kr3pamUiCmwodA==", "license": "MIT", "dependencies": { - "@sentry/bundler-plugin-core": "3.3.1", - "unplugin": "1.0.1", - "uuid": "^9.0.0" + "@sentry/bundler-plugin-core": "3.3.1", + "unplugin": "1.0.1", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "webpack": ">=4.40.0" + } + }, + "node_modules/@sentry/webpack-plugin/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/chunkify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/chunkify/-/chunkify-1.0.0.tgz", + "integrity": "sha512-YJOcVaEasXWcttXetXn0jd6Gtm9wFHQ1gViTPcxhESwkMCOoA4kwFsNr9EGcmsARGx7jXQZWmOR4zQotRcI9hw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/df": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/df/-/df-3.1.1.tgz", + "integrity": "sha512-SME/vtXaJcnQ/HpeV6P82Egy+jThn11IKfwW8+/XVoRD0rmPHVTeKMtww1oWdVnMykzVPjmrDN9S8NBndPEHCQ==", + "license": "MIT", + "dependencies": { + "execa": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sindresorhus/df/node_modules/execa": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-2.1.0.tgz", + "integrity": "sha512-Y/URAVapfbYy2Xp/gb6A0E7iR8xeqOCXsuuaoMn7A5PzrXUK84E1gyiEfq0wQd/GHA6GsoHWwhNq8anb0mleIw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^3.0.0", + "onetime": "^5.1.0", + "p-finally": "^2.0.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": "^8.12.0 || >=9.7.0" + } + }, + "node_modules/@sindresorhus/df/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/df/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/df/node_modules/npm-run-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-3.1.0.tgz", + "integrity": "sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" }, "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "webpack": ">=4.40.0" + "node": ">=8" } }, - "node_modules/@sentry/webpack-plugin/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" + "node_modules/@sindresorhus/df/node_modules/p-finally": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz", + "integrity": "sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==", + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/@sinclair/typebox": { - "version": "0.34.38", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", - "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", - "dev": true, - "license": "MIT" - }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -6989,6 +7460,15 @@ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" }, + "node_modules/@stroncium/procfs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@stroncium/procfs/-/procfs-1.2.1.tgz", + "integrity": "sha512-X1Iui3FUNZP18EUvysTHxt+Avu2nlVzyf90YM8OYgP6SGzTzzX/0JgObfO1AQQDzuZtNNz29bVh8h5R97JrjxA==", + "license": "CC0-1.0", + "engines": { + "node": ">=8" + } + }, "node_modules/@studio/eslint-plugin": { "resolved": "packages/eslint-plugin-studio", "link": true @@ -7684,6 +8164,17 @@ "@types/pg": "*" } }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -7829,6 +8320,16 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" }, + "node_modules/@types/unzipper": { + "version": "0.10.11", + "resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.11.tgz", + "integrity": "sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -9870,7 +10371,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, "dependencies": { "type-fest": "^0.21.3" }, @@ -9885,7 +10385,6 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, "engines": { "node": ">=10" }, @@ -10118,6 +10617,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/array.prototype.findlastindex": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", @@ -11250,9 +11770,10 @@ } }, "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", + "license": "MIT" }, "node_modules/chokidar": { "version": "3.6.0", @@ -11446,9 +11967,13 @@ } }, "node_modules/cli-width": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", - "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } }, "node_modules/clipboard": { "version": "2.0.11", @@ -12623,6 +13148,39 @@ "p-limit": "^3.1.0 " } }, + "node_modules/dir-glob": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", + "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==", + "license": "MIT", + "dependencies": { + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/dir-glob/node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/dir-glob/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -13261,6 +13819,24 @@ "node": ">=6.0.0" } }, + "node_modules/electron2appx/node_modules/ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/electron2appx/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/electron2appx/node_modules/ansi-styles": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", @@ -13285,6 +13861,24 @@ "node": ">=4" } }, + "node_modules/electron2appx/node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/electron2appx/node_modules/cli-width": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", + "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==", + "license": "ISC" + }, "node_modules/electron2appx/node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -13332,6 +13926,39 @@ "node": ">=4" } }, + "node_modules/electron2appx/node_modules/inquirer": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.5.2.tgz", + "integrity": "sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^3.2.0", + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.3", + "figures": "^2.0.0", + "lodash": "^4.17.12", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^6.4.0", + "string-width": "^2.1.0", + "strip-ansi": "^5.1.0", + "through": "^2.3.6" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/electron2appx/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/electron2appx/node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -13340,12 +13967,119 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/electron2appx/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/electron2appx/node_modules/mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==", + "license": "ISC" + }, + "node_modules/electron2appx/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/electron2appx/node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", "engines": { - "node": ">=4" + "node": ">=4" + } + }, + "node_modules/electron2appx/node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/electron2appx/node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/electron2appx/node_modules/rxjs": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "npm": ">=2.0.0" + } + }, + "node_modules/electron2appx/node_modules/string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "license": "MIT", + "dependencies": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/electron2appx/node_modules/string-width/node_modules/ansi-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/electron2appx/node_modules/string-width/node_modules/strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/electron2appx/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" } }, "node_modules/electron2appx/node_modules/supports-color": { @@ -13359,6 +14093,12 @@ "node": ">=4" } }, + "node_modules/electron2appx/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/electron2appx/node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -13430,7 +14170,6 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "devOptional": true, "dependencies": { "once": "^1.4.0" } @@ -14484,6 +15223,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "license": "MIT", "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", @@ -14493,10 +15233,17 @@ "node": ">=4" } }, + "node_modules/external-editor/node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "license": "MIT" + }, "node_modules/external-editor/node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "license": "MIT", "dependencies": { "os-tmpdir": "~1.0.2" }, @@ -14658,6 +15405,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", + "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -14669,6 +15417,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", "engines": { "node": ">=0.8.0" } @@ -15579,6 +16328,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-7.1.1.tgz", + "integrity": "sha512-yANWAN2DUcBtuus5Cpd+SKROzXHs2iVXFZt/Ykrfz6SAXqacLX25NZpltE+39ceMexYF4TtEadjuSTw8+3wX4g==", + "license": "MIT", + "dependencies": { + "array-union": "^1.0.1", + "dir-glob": "^2.0.0", + "glob": "^7.1.2", + "ignore": "^3.3.5", + "pify": "^3.0.0", + "slash": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", + "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==", + "license": "MIT" + }, + "node_modules/globby/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/globby/node_modules/slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha512-3TYDR7xWt4dIqV2JauJr+EJeW356RXijHeUlO+8djJ+uBXPn8/2dpzBc8yQhh583sVvc9CvFAeQVgijsH+PNNg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/good-listener": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", @@ -16231,325 +17021,114 @@ } }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-in-the-middle": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.13.1.tgz", - "integrity": "sha512-k2V9wNm9B+ysuelDTHjI9d5KPc4l8zAZTGqj+pcynvWkypZd857ryzN8jNC7Pg2YZXNMJcHRPpaDyCBbNyVRpA==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.14.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" - } - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imul": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/imul/-/imul-1.0.1.tgz", - "integrity": "sha512-WFAgfwPLAjU66EKt6vRdTlKj4nAgIDQzh29JonLa4Bqtl6D8JrIMvWjCnx7xEjVNmP3U0fM5o8ZObk7d0f62bA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, - "license": "ISC" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/inline-style-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.3.tgz", - "integrity": "sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==" - }, - "node_modules/inquirer": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.5.2.tgz", - "integrity": "sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==", - "dependencies": { - "ansi-escapes": "^3.2.0", - "chalk": "^2.4.2", - "cli-cursor": "^2.1.0", - "cli-width": "^2.0.0", - "external-editor": "^3.0.3", - "figures": "^2.0.0", - "lodash": "^4.17.12", - "mute-stream": "0.0.7", - "run-async": "^2.2.0", - "rxjs": "^6.4.0", - "string-width": "^2.1.0", - "strip-ansi": "^5.1.0", - "through": "^2.3.6" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/inquirer/node_modules/ansi-escapes": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", - "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", - "engines": { - "node": ">=4" - } - }, - "node_modules/inquirer/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "engines": { - "node": ">=6" - } - }, - "node_modules/inquirer/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/inquirer/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/inquirer/node_modules/cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", - "dependencies": { - "restore-cursor": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/inquirer/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/inquirer/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/inquirer/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/inquirer/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/inquirer/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "engines": { - "node": ">=4" - } - }, - "node_modules/inquirer/node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", - "engines": { - "node": ">=4" - } - }, - "node_modules/inquirer/node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dependencies": { - "mimic-fn": "^1.0.0" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inquirer/node_modules/restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "node_modules/import-in-the-middle": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.13.1.tgz", + "integrity": "sha512-k2V9wNm9B+ysuelDTHjI9d5KPc4l8zAZTGqj+pcynvWkypZd857ryzN8jNC7Pg2YZXNMJcHRPpaDyCBbNyVRpA==", + "license": "Apache-2.0", "dependencies": { - "onetime": "^2.0.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=4" + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" } }, - "node_modules/inquirer/node_modules/rxjs": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^1.9.0" + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" }, "engines": { - "npm": ">=2.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inquirer/node_modules/string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dependencies": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - }, + "node_modules/imul": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/imul/-/imul-1.0.1.tgz", + "integrity": "sha512-WFAgfwPLAjU66EKt6vRdTlKj4nAgIDQzh29JonLa4Bqtl6D8JrIMvWjCnx7xEjVNmP3U0fM5o8ZObk7d0f62bA==", + "license": "MIT", + "optional": true, "engines": { - "node": ">=4" + "node": ">=0.10.0" } }, - "node_modules/inquirer/node_modules/string-width/node_modules/ansi-regex": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", - "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "engines": { - "node": ">=4" + "node": ">=0.8.19" } }, - "node_modules/inquirer/node_modules/string-width/node_modules/strip-ansi": { + "node_modules/indent-string": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", - "dependencies": { - "ansi-regex": "^3.0.0" - }, + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "engines": { - "node": ">=4" + "node": ">=8" } }, - "node_modules/inquirer/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" + "once": "^1.3.0", + "wrappy": "1" } }, - "node_modules/inquirer/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=4" + "node": ">=10" } }, - "node_modules/inquirer/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "node_modules/inline-style-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.3.tgz", + "integrity": "sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==" }, "node_modules/internal-slot": { "version": "1.1.0", @@ -20550,7 +21129,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, "engines": { "node": ">=6" } @@ -20841,11 +21419,58 @@ "node": "*" } }, + "node_modules/mount-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mount-point/-/mount-point-3.0.0.tgz", + "integrity": "sha512-jAhfD7ZCG+dbESZjcY1SdFVFqSJkh/yGbdsifHcPkvuLRO5ugK0Ssmd9jdATu29BTd4JiN+vkpMzVvsUgP3SZA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/df": "^1.0.1", + "pify": "^2.3.0", + "pinkie-promise": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mount-point/node_modules/@sindresorhus/df": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/df/-/df-1.0.1.tgz", + "integrity": "sha512-1Hyp7NQnD/u4DSxR2DGW78TF9k7R0wZ8ev0BpMAIzA6yTQSHqNb5wTuvtcPYf4FWbVse2rW7RgDsyL8ua2vXHw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mousetrap": { "version": "1.6.5", "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", "integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==" }, + "node_modules/move-file": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/move-file/-/move-file-3.1.0.tgz", + "integrity": "sha512-4aE3U7CCBWgrQlQDMq8da4woBWDGHioJFiOZ8Ie6Yq2uwYQ9V2kGhTz4x3u6Wc+OU17nw0yc3rJ/lQ4jIiPe3A==", + "license": "MIT", + "dependencies": { + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/move-file/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -20898,9 +21523,13 @@ } }, "node_modules/mute-stream": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", - "integrity": "sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } }, "node_modules/mz": { "version": "2.7.0", @@ -21407,7 +22036,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, "dependencies": { "mimic-fn": "^2.1.0" }, @@ -21597,6 +22225,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -22115,6 +22752,27 @@ "node": ">=0.10.0" } }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -22749,7 +23407,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "devOptional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -23706,14 +24363,6 @@ "dev": true, "license": "MIT" }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -24866,7 +25515,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, "engines": { "node": ">=6" } @@ -25556,7 +26204,8 @@ "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" }, "node_modules/thunky": { "version": "1.1.0", @@ -25691,6 +26340,51 @@ "node": ">=18" } }, + "node_modules/trash": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/trash/-/trash-9.0.0.tgz", + "integrity": "sha512-6U3A0olN4C16iiPZvoF93AcZDNZtv/nI2bHb2m/sO3h/m8VPzg9tPdd3n3LVcYLWz7ui0AHaXYhIuRjzGW9ptg==", + "license": "MIT", + "dependencies": { + "@sindresorhus/chunkify": "^1.0.0", + "@stroncium/procfs": "^1.2.1", + "globby": "^7.1.1", + "is-path-inside": "^4.0.0", + "move-file": "^3.1.0", + "p-map": "^7.0.2", + "xdg-trashdir": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/trash/node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/trash/node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/traverse": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", @@ -26583,6 +27277,18 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/user-home": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz", + "integrity": "sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==", + "license": "MIT", + "dependencies": { + "os-homedir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/username": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/username/-/username-5.1.0.tgz", @@ -27486,6 +28192,30 @@ } } }, + "node_modules/xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/xdg-trashdir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/xdg-trashdir/-/xdg-trashdir-3.1.0.tgz", + "integrity": "sha512-N1XQngeqMBoj9wM4ZFadVV2MymImeiFfYD+fJrNlcVcOHsJFFQe7n3b+aBoTPwARuq2HQxukfzVpQmAk1gN4sQ==", + "license": "MIT", + "dependencies": { + "@sindresorhus/df": "^3.1.1", + "mount-point": "^3.0.0", + "user-home": "^2.0.0", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -27659,6 +28389,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zip-stream": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.1.tgz", diff --git a/package.json b/package.json index 4ce126e207..9e9d2cbba9 100644 --- a/package.json +++ b/package.json @@ -60,10 +60,12 @@ "@types/lockfile": "^1.0.4", "@types/lodash": "^4.17.20", "@types/node-fetch": "^2.6.12", + "@types/plist": "^3.0.5", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", + "@types/unzipper": "^0.10.11", "@types/winreg": "^1.2.36", "@types/yauzl": "^2.10.3", "@typescript-eslint/eslint-plugin": "^8.39.1", @@ -117,6 +119,7 @@ "@automattic/interpolate-components": "^1.2.1", "@formatjs/intl-locale": "^3.4.5", "@formatjs/intl-localematcher": "^0.5.4", + "@inquirer/prompts": "^7.8.4", "@php-wasm/node": "^2.0.15", "@php-wasm/scopes": "^2.0.15", "@php-wasm/universal": "^2.0.15", @@ -163,6 +166,7 @@ "shell-quote": "^1.8.1", "strip-ansi": "^7.1.0", "tar": "^7.4.0", + "trash": "^9.0.0", "unzipper": "0.10.11", "url-loader": "^4.1.1", "winreg": "1.2.4", diff --git a/scripts/download-wp-server-files.ts b/scripts/download-wp-server-files.ts index a40ecfd61b..ae4e203a39 100644 --- a/scripts/download-wp-server-files.ts +++ b/scripts/download-wp-server-files.ts @@ -1,107 +1,17 @@ -import os from 'os'; import path from 'path'; -import extract from 'extract-zip'; -import fs from 'fs-extra'; -import { SQLITE_DATABASE_INTEGRATION_RELEASE_URL } from '../src/constants'; -import { download } from '../src/lib/download'; -import { getLatestSQLiteCommandRelease } from '../src/lib/sqlite-command-release'; -const WP_SERVER_FILES_PATH = path.join( __dirname, '..', 'wp-files' ); +import { downloadFiles, getWordPressResourceFiles } from '../common/lib/resource-downloader'; -interface FileToDownload { - name: string; - description: string; - url: string | ( () => Promise< string > ); - destinationPath?: string; -} +const WP_SERVER_FILES_PATH = path.join( __dirname, '..', 'wp-files' ); -const FILES_TO_DOWNLOAD: FileToDownload[] = [ - { - name: 'wordpress', - description: 'latest WordPress version', - url: 'https://wordpress.org/latest.zip', - destinationPath: path.join( WP_SERVER_FILES_PATH, 'latest' ), - }, - { - name: 'sqlite', - description: 'SQLite files', - url: SQLITE_DATABASE_INTEGRATION_RELEASE_URL, - }, - { - name: 'wp-cli', - description: 'WP-CLI phar file', - url: 'https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar', - destinationPath: path.join( WP_SERVER_FILES_PATH, 'wp-cli' ), - }, - { - name: 'sqlite-command', - description: 'SQLite command', - url: async () => { - const latestRelease = await getLatestSQLiteCommandRelease(); - return latestRelease.assets?.[ 0 ].browser_download_url ?? ''; - }, - destinationPath: path.join( WP_SERVER_FILES_PATH, 'sqlite-command' ), - }, -]; +const downloadAllFiles = async () => { + const files = getWordPressResourceFiles( WP_SERVER_FILES_PATH ); -const downloadFile = async ( file: FileToDownload ) => { - const { name, description, destinationPath } = file; - const url = await getUrl( file.url ); - console.log( `[${ name }] Downloading ${ description } ...` ); - const zipPath = path.join( os.tmpdir(), `${ name }.zip` ); - const extractedPath = destinationPath ?? WP_SERVER_FILES_PATH; try { - fs.ensureDirSync( extractedPath ); + await downloadFiles( files, WP_SERVER_FILES_PATH ); } catch ( err ) { - const fsError = err as { code: string }; - if ( fsError.code !== 'EEXIST' ) throw err; - } - - await download( url, zipPath, true, name ); - - if ( name === 'wp-cli' ) { - console.log( `[${ name }] Moving WP-CLI to destination ...` ); - fs.moveSync( zipPath, path.join( extractedPath, 'wp-cli.phar' ), { overwrite: true } ); - } else if ( name === 'sqlite' ) { - /** - * The SQLite database integration plugin is extracted - * into a folder with the version number like sqlite-database-integration-1.0.0 - * We need to move the contents of that folder to the sqlite-database-integration folder - */ - await extract( zipPath, { dir: extractedPath } ); - - const files = fs.readdirSync( extractedPath ); - const sqliteFolder = files.find( ( file ) => - file.startsWith( 'sqlite-database-integration-' ) - ); - - if ( sqliteFolder ) { - const sourcePath = path.join( extractedPath, sqliteFolder ); - const targetPath = path.join( extractedPath, 'sqlite-database-integration' ); - if ( fs.existsSync( targetPath ) ) { - fs.rmSync( targetPath, { recursive: true, force: true } ); - } - fs.renameSync( sourcePath, targetPath ); - } - } else { - console.log( `[${ name }] Extracting files from zip ...` ); - await extract( zipPath, { dir: extractedPath } ); - } - console.log( `[${ name }] Files extracted` ); -}; - -async function getUrl( url: string | ( () => Promise< string > ) ): Promise< string > { - return typeof url === 'function' ? await url() : url; -} - -const downloadFiles = async () => { - for ( const file of FILES_TO_DOWNLOAD ) { - try { - await downloadFile( file ); - } catch ( err ) { - console.error( err ); - process.exit( 1 ); - } + console.error( err ); + process.exit( 1 ); } }; -downloadFiles(); +downloadAllFiles(); diff --git a/src/components/site-form.tsx b/src/components/site-form.tsx index db1d70bcf0..a097446056 100644 --- a/src/components/site-form.tsx +++ b/src/components/site-form.tsx @@ -4,6 +4,7 @@ import { __, sprintf, _n } from '@wordpress/i18n'; import { tip, warning, trash, chevronRight, chevronDown, chevronLeft } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { FormEvent, useRef, useState, useEffect } from 'react'; +import { AllowedPHPVersion } from 'common/lib/wordpress-provider/constants'; import Button from 'src/components/button'; import FolderIcon from 'src/components/folder-icon'; import TextControlComponent from 'src/components/text-control'; @@ -13,7 +14,6 @@ import { cx } from 'src/lib/cx'; import { generateCustomDomainFromSiteName } from 'src/lib/domains'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getLocalizedLink } from 'src/lib/get-localized-link'; -import { AllowedPHPVersion } from 'src/lib/wordpress-provider/constants'; import { useRootSelector, useI18nLocale } from 'src/stores'; import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api'; import { diff --git a/src/components/wp-version-selector/add-wp-version-to-list.test.ts b/src/components/wp-version-selector/add-wp-version-to-list.test.ts deleted file mode 100644 index f77f268d46..0000000000 --- a/src/components/wp-version-selector/add-wp-version-to-list.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { addWpVersionToList } from './add-wp-version-to-list'; - -describe( 'addWpVersionToList', () => { - it( 'should add version to empty list', () => { - const options: Array< { label: string; value: string } > = []; - const result = addWpVersionToList( { label: '6.4.2', value: '6.4.2' }, options ); - expect( result ).toEqual( [ { label: '6.4.2', value: '6.4.2' } ] ); - } ); - - it( 'should add version at the top when it is newer than all existing versions', () => { - const options = [ - { label: '6.4.1', value: '6.4.1' }, - { label: '6.4.0', value: '6.4.0' }, - ]; - const result = addWpVersionToList( { label: '6.4.3', value: '6.4.3' }, options ); - expect( result ).toEqual( [ - { label: '6.4.3', value: '6.4.3' }, - { label: '6.4.1', value: '6.4.1' }, - { label: '6.4.0', value: '6.4.0' }, - ] ); - } ); - - it( 'should add version at the end when it is older than all existing versions', () => { - const options = [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.1', value: '6.4.1' }, - ]; - const result = addWpVersionToList( { label: '6.4.0', value: '6.4.0' }, options ); - expect( result ).toEqual( [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.1', value: '6.4.1' }, - { label: '6.4.0', value: '6.4.0' }, - ] ); - } ); - - it( 'should add version in the middle when it is between existing versions', () => { - const options = [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.0', value: '6.4.0' }, - ]; - const result = addWpVersionToList( { label: '6.4.1', value: '6.4.1' }, options ); - expect( result ).toEqual( [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.1', value: '6.4.1' }, - { label: '6.4.0', value: '6.4.0' }, - ] ); - } ); - - it( 'should handle non-semver versions by adding them at the end', () => { - const options = [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.0', value: '6.4.0' }, - ]; - const result = addWpVersionToList( { label: 'nightly', value: 'nightly' }, options ); - expect( result ).toEqual( [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.0', value: '6.4.0' }, - { label: 'nightly', value: 'nightly' }, - ] ); - } ); - - it( 'should handle mixed semver and non-semver versions', () => { - const options = [ - { label: 'nightly', value: 'nightly' }, - { label: '6.4.0', value: '6.4.0' }, - ]; - const result = addWpVersionToList( { label: '6.4.1', value: '6.4.1' }, options ); - expect( result ).toEqual( [ - { label: 'nightly', value: 'nightly' }, - { label: '6.4.1', value: '6.4.1' }, - { label: '6.4.0', value: '6.4.0' }, - ] ); - } ); - - it( 'should handle beta versions correctly', () => { - const options = [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.0', value: '6.4.0' }, - ]; - const result = addWpVersionToList( { label: '6.4.1-beta2', value: '6.4.1-beta2' }, options ); - expect( result ).toEqual( [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.1-beta2', value: '6.4.1-beta2' }, - { label: '6.4.0', value: '6.4.0' }, - ] ); - } ); - - it( 'should handle RC versions correctly', () => { - const options = [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.0', value: '6.4.0' }, - ]; - const result = addWpVersionToList( { label: '6.4.1-RC1', value: '6.4.1-RC1' }, options ); - expect( result ).toEqual( [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.1-RC1', value: '6.4.1-RC1' }, - { label: '6.4.0', value: '6.4.0' }, - ] ); - } ); - - it( 'should handle multiple pre-release versions', () => { - const options = [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.1-beta2', value: '6.4.1-beta2' }, - { label: '6.4.0', value: '6.4.0' }, - ]; - const result = addWpVersionToList( { label: '6.4.1-beta1', value: '6.4.1-beta1' }, options ); - expect( result ).toEqual( [ - { label: '6.4.2', value: '6.4.2' }, - { label: '6.4.1-beta2', value: '6.4.1-beta2' }, - { label: '6.4.1-beta1', value: '6.4.1-beta1' }, - { label: '6.4.0', value: '6.4.0' }, - ] ); - } ); -} ); diff --git a/src/components/wp-version-selector/add-wp-version-to-list.ts b/src/components/wp-version-selector/add-wp-version-to-list.ts deleted file mode 100644 index f83bd6bcfb..0000000000 --- a/src/components/wp-version-selector/add-wp-version-to-list.ts +++ /dev/null @@ -1,27 +0,0 @@ -import semver from 'semver'; - -type Option = { label: string; value: string }; - -export const addWpVersionToList = ( newOption: Option, options: Option[] ): Option[] => { - const currentVer = semver.coerce( newOption.value ); - - // Being extra cautious here, if the version is not valid, we add it to the end of the list. - if ( ! currentVer ) { - return [ ...options, newOption ]; - } - - const firstOlderVersionIndex = options.findIndex( ( compareVersion ) => { - const compareVer = semver.coerce( compareVersion.value ); - return compareVer && semver.gt( currentVer, compareVer ); - } ); - - if ( firstOlderVersionIndex === -1 ) { - return [ ...options, newOption ]; - } else { - return [ - ...options.slice( 0, firstOlderVersionIndex ), - newOption, - ...options.slice( firstOlderVersionIndex ), - ]; - } -}; diff --git a/src/components/wp-version-selector/index.tsx b/src/components/wp-version-selector/index.tsx index 248513aba9..83af64f2c5 100644 --- a/src/components/wp-version-selector/index.tsx +++ b/src/components/wp-version-selector/index.tsx @@ -2,16 +2,16 @@ import { SelectControl, Icon } from '@wordpress/components'; import { info } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useEffect } from 'react'; +import { isWordPressBetaVersion } from 'common/lib/wordpress-version-utils'; +import { getGroupedWordPressVersions } from 'common/lib/wp-org/version-groups'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; import { useOffline } from 'src/hooks/use-offline'; import { cx } from 'src/lib/cx'; import { isWordPressDevVersion } from 'src/lib/version-utils'; -import { isWordPressBetaVersion } from 'src/lib/wordpress-version-utils'; import { useRootSelector } from 'src/stores'; import { selectDefaultWordPressVersion } from 'src/stores/provider-constants-slice'; import { useGetWordPressVersions } from 'src/stores/wordpress-versions-api'; -import { addWpVersionToList } from './add-wp-version-to-list'; type WPVersionSelectorProps = { selectedValue: string; @@ -50,27 +50,20 @@ export const WPVersionSelector = ( { } }, [ isOffline, onChange, defaultWordPressVersion ] ); - let betaVersions: { label: string; value: string }[] = wpVersions.filter( - ( version ) => version.isBeta || version.isDevelopment - ); - let stableVersions: { label: string; value: string }[] = wpVersions.filter( - ( version ) => ! version.isBeta && ! version.isDevelopment && version.value !== 'latest' - ); + const versionsWithExtras = [ ...wpVersions ]; extraOptions?.forEach( ( extraOption ) => { const alreadyExists = wpVersions.some( ( version ) => version.value === extraOption.value ); - if ( alreadyExists ) { - return; - } - - if ( - isWordPressBetaVersion( extraOption.value ) || - isWordPressDevVersion( extraOption.value ) - ) { - betaVersions = addWpVersionToList( extraOption, betaVersions ); - } else { - stableVersions = addWpVersionToList( extraOption, stableVersions ); + if ( ! alreadyExists ) { + // Convert to WordPressVersion format and add to list + const extraVersion = { + ...extraOption, + isBeta: isWordPressBetaVersion( extraOption.value ), + isDevelopment: isWordPressDevVersion( extraOption.value ), + }; + versionsWithExtras.push( extraVersion ); } } ); + const groups = getGroupedWordPressVersions( versionsWithExtras ); return (