From d2cc716711101acd7a8304b0eb007c1753ed040a Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Sat, 30 Aug 2025 14:32:47 +0200 Subject: [PATCH 01/13] CLI: Add Sites list command --- cli/commands/sites/list.ts | 94 ++++++++++++++++++++++ cli/commands/sites/tests/list.test.ts | 110 ++++++++++++++++++++++++++ cli/index.ts | 5 ++ 3 files changed, 209 insertions(+) create mode 100644 cli/commands/sites/list.ts create mode 100644 cli/commands/sites/tests/list.test.ts diff --git a/cli/commands/sites/list.ts b/cli/commands/sites/list.ts new file mode 100644 index 0000000000..2a0071c98e --- /dev/null +++ b/cli/commands/sites/list.ts @@ -0,0 +1,94 @@ +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(); + + // Combine both regular sites and new sites + const allSites = [ ...appdata.sites, ...appdata.newSites ]; + + 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/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..e6cf1b7678 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -7,6 +7,7 @@ import { registerCommand as registerCreateCommand } from 'cli/commands/preview/c 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 registerSitesListCommand } from 'cli/commands/sites/list'; import { loadTranslations } from 'cli/lib/i18n'; import { bumpAggregatedUniqueStat } from 'cli/lib/stats'; import { version } from 'cli/package.json'; @@ -49,6 +50,10 @@ async function main() { registerUpdateCommand( previewYargs ); previewYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); } ) + .command( 'sites', __( 'Manage local sites' ), ( sitesYargs ) => { + registerSitesListCommand( sitesYargs ); + sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); + } ) .demandCommand( 1, __( 'You must provide a valid command' ) ) .strict(); From 9786b589ba1739348f6f312235ed8fbaba35ba62 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Sat, 30 Aug 2025 15:53:46 +0200 Subject: [PATCH 02/13] CLI: Add Sites create command --- cli/commands/preview/create.ts | 12 +- cli/commands/preview/list.ts | 7 +- cli/commands/preview/tests/create.test.ts | 13 +- cli/commands/preview/tests/list.test.ts | 11 +- cli/commands/preview/tests/update.test.ts | 12 +- cli/commands/preview/update.ts | 7 +- cli/commands/sites/create.ts | 200 ++++ cli/commands/sites/tests/create.test.ts | 194 ++++ cli/index.ts | 2 + cli/lib/tests/validation.test.ts | 55 +- cli/lib/validation.ts | 84 +- .../lib/wordpress-provider/constants.ts | 0 common/lib/wp-org/version-groups.ts | 38 + common/lib/wp-org/versions.ts | 159 ++++ package-lock.json | 851 +++++++++++++----- package.json | 1 + src/components/site-form.tsx | 2 +- .../add-wp-version-to-list.test.ts | 115 --- .../add-wp-version-to-list.ts | 27 - src/components/wp-version-selector/index.tsx | 60 +- src/hooks/use-add-site.ts | 2 +- .../wp-now/wp-now-provider.ts | 14 +- .../lib/tests/version-comparison.test.ts | 2 +- .../preview-site/lib/version-comparison.ts | 2 +- .../site-settings/edit-site-details.tsx | 2 +- src/stores/wordpress-versions-api.ts | 157 +--- 26 files changed, 1383 insertions(+), 646 deletions(-) create mode 100644 cli/commands/sites/create.ts create mode 100644 cli/commands/sites/tests/create.test.ts rename {src => common}/lib/wordpress-provider/constants.ts (100%) create mode 100644 common/lib/wp-org/version-groups.ts create mode 100644 common/lib/wp-org/versions.ts delete mode 100644 src/components/wp-version-selector/add-wp-version-to-list.test.ts delete mode 100644 src/components/wp-version-selector/add-wp-version-to-list.ts 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..aab8b44cd9 --- /dev/null +++ b/cli/commands/sites/create.ts @@ -0,0 +1,200 @@ +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 { arePathsEqual } from 'common/lib/fs-utils'; +import { + DEFAULT_PHP_VERSION, + ALLOWED_PHP_VERSIONS, + DEFAULT_WORDPRESS_VERSION, +} from 'common/lib/wordpress-provider/constants'; +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 { 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 checkPathInUse( sitePath: string ): Promise< boolean > { + try { + const appdata = await readAppdata(); + const allSites = [ ...appdata.sites, ...appdata.newSites ]; + return allSites.some( ( site ) => arePathsEqual( site.path, sitePath ) ); + } catch { + return false; + } +} + +async function promptForSiteData(): Promise< SiteCreationData > { + const sitePath = await input( { + message: __( 'Site path:' ), + default: process.cwd(), + validate: async ( inputPath: string ) => { + const pathValidation = validateCreateSitePath( inputPath ); + if ( ! pathValidation.valid ) { + return false; + } + + const resolvedPath = path.resolve( inputPath ); + const inUse = await checkPathInUse( resolvedPath ); + if ( inUse ) { + return __( 'This path is already used by another site' ); + } + + return true; + }, + } ); + + 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 siteEntry = { + id: siteId, + name: siteData.name, + path: resolvedPath, + }; + + if ( ! fs.existsSync( resolvedPath ) ) { + fs.mkdirSync( resolvedPath, { recursive: true } ); + } + await lockAppdata(); + const appdata = await readAppdata(); + + const allSites = [ ...appdata.sites, ...appdata.newSites ]; + const pathInUse = allSites.some( ( site ) => arePathsEqual( site.path, resolvedPath ) ); + if ( pathInUse ) { + throw new LoggerError( __( 'This path is already used by another site' ) ); + } + + appdata.newSites.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( __( ' ID: %s' ), siteEntry.id ) ); + + console.log( __( '\nUse "studio sites list" to see all your sites.' ) ); + } catch ( error ) { + 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/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/index.ts b/cli/index.ts index e6cf1b7678..6954382397 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -7,6 +7,7 @@ import { registerCommand as registerCreateCommand } from 'cli/commands/preview/c 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 registerSitesListCommand } from 'cli/commands/sites/list'; import { loadTranslations } from 'cli/lib/i18n'; import { bumpAggregatedUniqueStat } from 'cli/lib/stats'; @@ -52,6 +53,7 @@ async function main() { } ) .command( 'sites', __( 'Manage local sites' ), ( sitesYargs ) => { registerSitesListCommand( sitesYargs ); + registerSitesCreateCommand( sitesYargs ); sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); } ) .demandCommand( 1, __( 'You must provide a valid command' ) ) 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/validation.ts b/cli/lib/validation.ts index 2c5d9d73bd..fefdfc11c9 100644 --- a/cli/lib/validation.ts +++ b/cli/lib/validation.ts @@ -3,39 +3,91 @@ 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 ): 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 ( ! 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 ) { + if ( ! isWordPressDirectory( resolvedPath ) ) { + return { + valid: false, + error: __( 'Directory is not empty and does not appear to be a WordPress site' ), + }; + } + } + 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/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/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/package-lock.json b/package-lock.json index 2750315560..21c83c6000 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", @@ -4053,6 +4054,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", @@ -9870,7 +10246,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 +10260,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" }, @@ -11250,9 +11624,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 +11821,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", @@ -13261,6 +13640,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 +13682,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,20 +13747,160 @@ "node": ">=4" } }, - "node_modules/electron2appx/node_modules/jsonfile": { + "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", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "optionalDependencies": { + "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_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/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "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/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "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": ">=4" + "node": ">=6" } }, "node_modules/electron2appx/node_modules/supports-color": { @@ -13359,6 +13914,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", @@ -14484,6 +15045,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 +15055,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 +15227,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 +15239,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" } @@ -16340,217 +16911,6 @@ "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==", - "dependencies": { - "mimic-fn": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "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==", - "dependencies": { - "onetime": "^2.0.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "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==", - "dependencies": { - "tslib": "^1.9.0" - }, - "engines": { - "npm": ">=2.0.0" - } - }, - "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" - }, - "engines": { - "node": ">=4" - } - }, - "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==", - "engines": { - "node": ">=4" - } - }, - "node_modules/inquirer/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==", - "dependencies": { - "ansi-regex": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "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==", - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "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" - }, - "engines": { - "node": ">=4" - } - }, - "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/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -20898,9 +21258,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", @@ -23706,14 +24070,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", @@ -25556,7 +25912,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", @@ -27659,6 +28016,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..4a01b90898 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,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", 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..85c0539579 100644 --- a/src/components/wp-version-selector/index.tsx +++ b/src/components/wp-version-selector/index.tsx @@ -2,6 +2,7 @@ import { SelectControl, Icon } from '@wordpress/components'; import { info } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useEffect } from 'react'; +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'; @@ -11,7 +12,6 @@ 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 (