diff --git a/src/components/app.tsx b/src/components/app.tsx index c0329b99d7..3eb90a9aec 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -4,9 +4,9 @@ import { } from '@wordpress/components'; import { useEffect } from 'react'; import MacTitlebar from 'src/components/mac-titlebar'; +import { MainContent } from 'src/components/main-content'; import MainSidebar from 'src/components/main-sidebar'; import { NoStudioSites } from 'src/components/no-studio-sites'; -import { SiteContentTabs } from 'src/components/site-content-tabs'; import TopBar from 'src/components/top-bar'; import WindowsTitlebar from 'src/components/windows-titlebar'; import { useLocalizationSupport } from 'src/hooks/use-localization-support'; @@ -18,6 +18,7 @@ import { getIpcApi } from 'src/lib/get-ipc-api'; import { Onboarding } from 'src/modules/onboarding'; import { useOnboarding } from 'src/modules/onboarding/hooks/use-onboarding'; import { UserSettings } from 'src/modules/user-settings'; +import { VipProvider } from 'src/modules/vip/context/vip-context'; import { WhatsNewModal, useWhatsNew } from 'src/modules/whats-new'; import { useRootSelector } from 'src/stores'; import { selectOnboardingLoading } from 'src/stores/onboarding-slice'; @@ -42,7 +43,7 @@ export default function App() { } return ( - <> + { needsOnboarding || isEmpty ? ( - + ) } - + ); } diff --git a/src/components/main-content.tsx b/src/components/main-content.tsx new file mode 100644 index 0000000000..a003816b75 --- /dev/null +++ b/src/components/main-content.tsx @@ -0,0 +1,30 @@ +import { useSiteDetails } from 'src/hooks/use-site-details'; +import { VipContentTabs } from 'src/modules/vip/components/vip-content-tabs'; +import { useVipContext } from 'src/modules/vip/context/vip-context'; +import { SiteContentTabs } from './site-content-tabs'; + +/** + * Main content area that displays either regular site content or VIP environment content + * based on which is currently selected. + * + * VIP environment is checked first because the regular site selection has fallback behavior + * that always returns a site when sites exist. When a user actively clicks a VIP environment, + * that selection should take priority. + */ +export function MainContent() { + const { selectedSite } = useSiteDetails(); + const { selectedEnvironment } = useVipContext(); + + // If a VIP environment is selected, show VIP content (check first due to regular site fallback behavior) + if ( selectedEnvironment ) { + return ; + } + + // If a regular site is selected, show regular site content + if ( selectedSite ) { + return ; + } + + // Default to regular site content tabs (which handles the "no site selected" state) + return ; +} diff --git a/src/components/main-sidebar.tsx b/src/components/main-sidebar.tsx index 2328d05602..de155c48e4 100644 --- a/src/components/main-sidebar.tsx +++ b/src/components/main-sidebar.tsx @@ -5,6 +5,7 @@ import { useSiteDetails } from 'src/hooks/use-site-details'; import { isMac } from 'src/lib/app-globals'; import { cx } from 'src/lib/cx'; import AddSite from 'src/modules/add-site'; +import VipSiteMenu from 'src/modules/vip/components/vip-site-menu'; interface MainSidebarProps { className?: string; @@ -12,6 +13,7 @@ interface MainSidebarProps { export default function MainSidebar( { className }: MainSidebarProps ) { const { sites: localSites } = useSiteDetails(); + const hasLocalSites = localSites.length > 0; return (
- { ! localSites.length ? ( -
- { __( 'Your sites will show up here once you create them' ) } -
- ) : ( -
-
+
+
+ { hasLocalSites ? ( -
-
- -
- + ) : ( +
+ { __( 'Your sites will show up here once you create them' ) }
+ ) } + +
+
+ +
+
- ) } +
); } diff --git a/src/components/site-menu.tsx b/src/components/site-menu.tsx index 542a317f03..e3a60886c0 100644 --- a/src/components/site-menu.tsx +++ b/src/components/site-menu.tsx @@ -15,6 +15,7 @@ import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { supportedEditorConfig } from 'src/modules/user-settings/lib/editor'; import { getTerminalName } from 'src/modules/user-settings/lib/terminal'; +import { useVipContext } from 'src/modules/vip/context/vip-context'; import { useGetUserEditorQuery, useGetUserTerminalQuery } from 'src/stores/installed-apps-api'; interface SiteMenuProps { @@ -134,6 +135,7 @@ function ButtonToRun( { } function SiteItem( { site }: { site: SiteDetails } ) { const { selectedSite, setSelectedSiteId, loadingServer, isSiteDeleting } = useSiteDetails(); + const { selectEnvironment } = useVipContext(); const isSelected = site === selectedSite; const { isSiteImporting, isSiteExporting } = useImportExport(); const { isSiteIdPulling, isSiteIdPushing } = useSyncSites(); @@ -193,6 +195,8 @@ function SiteItem( { site }: { site: SiteDetails } ) { + + + { __( 'Leave empty to use demo code, or select your VIP app repository' ) } + +
+ + { /* Advanced Settings Toggle */ } + + + { showAdvanced && ( + + { /* MU Plugins Path */ } +
+ + { __( 'VIP MU Plugins' ) } + + + updateField( 'muPluginsPath', value ) } + placeholder={ __( 'Demo (default)' ) } + className="flex-1" + /> + + +
+ + { /* Media Redirect Domain */ } + updateField( 'mediaRedirectDomain', value ) } + placeholder="https://example.com" + /> + + { /* Service Toggles */ } + + + { __( 'Optional Services' ) } + + updateField( 'elasticsearch', value ) } + /> + updateField( 'phpmyadmin', value ) } + /> + updateField( 'xdebug', value ) } + /> + updateField( 'mailpit', value ) } + /> + updateField( 'photon', value ) } + /> + updateField( 'cron', value ) } + /> + +
+ ) } + + + ); +} + +export default forwardRef( CreateVipSiteForm ); diff --git a/src/modules/add-site/components/options.tsx b/src/modules/add-site/components/options.tsx index e1e7c2deb7..2591afeb8f 100644 --- a/src/modules/add-site/components/options.tsx +++ b/src/modules/add-site/components/options.tsx @@ -11,13 +11,15 @@ import { useFeatureFlags } from 'src/hooks/use-feature-flags'; import { useOffline } from 'src/hooks/use-offline'; import { cx } from 'src/lib/cx'; import { BlueprintIcon } from './blueprint-icon'; +import { VipIcon } from './vip-icon'; export type AddSiteFlowType = | 'create' | 'blueprint' | 'blueprintDeeplink' | 'backup' - | 'pullRemote'; + | 'pullRemote' + | 'vip'; interface AddSiteOptionsProps { onOptionSelect: ( option: AddSiteFlowType ) => void; } @@ -102,6 +104,13 @@ export default function AddSiteOptions( { onOptionSelect }: AddSiteOptionsProps onClick={ () => onOptionSelect( 'create' ) } testId="create-site-option-button" /> + } + title={ __( 'Create a VIP site' ) } + description={ __( 'Create a VIP Local Development Environment' ) } + onClick={ () => onOptionSelect( 'vip' ) } + testId="create-vip-site-option-button" + /> { enableBlueprints && ( } diff --git a/src/modules/add-site/components/stepper.tsx b/src/modules/add-site/components/stepper.tsx index 9888c5b432..9249a8a4e0 100644 --- a/src/modules/add-site/components/stepper.tsx +++ b/src/modules/add-site/components/stepper.tsx @@ -13,11 +13,13 @@ interface StepperProps { onBackupContinue?: () => void; onPullRemoteContinue?: () => void; onCreateSubmit?: ( event: FormEvent ) => void; + onVipSubmit?: ( event: FormEvent ) => void; canSubmitBlueprint?: boolean; canSubmitBlueprintDeeplink?: boolean; canSubmitBackup?: boolean; canSubmitPullRemote?: boolean; canSubmitCreate?: boolean; + canSubmitVip?: boolean; } export default function Stepper( { @@ -28,11 +30,13 @@ export default function Stepper( { onBackupContinue, onPullRemoteContinue, onCreateSubmit, + onVipSubmit, canSubmitBlueprint, canSubmitBlueprintDeeplink, canSubmitBackup, canSubmitPullRemote, canSubmitCreate, + canSubmitVip, }: StepperProps ) { const { __ } = useI18n(); const { steps, isVisible, actionButton, onSubmit, canSubmit } = useStepper( { @@ -41,11 +45,13 @@ export default function Stepper( { onBackupContinue, onPullRemoteContinue, onCreateSubmit, + onVipSubmit, canSubmitBlueprint, canSubmitBlueprintDeeplink, canSubmitBackup, canSubmitPullRemote, canSubmitCreate, + canSubmitVip, } ); if ( ! isVisible ) { diff --git a/src/modules/add-site/components/vip-icon.tsx b/src/modules/add-site/components/vip-icon.tsx new file mode 100644 index 0000000000..6eeeb4888d --- /dev/null +++ b/src/modules/add-site/components/vip-icon.tsx @@ -0,0 +1,36 @@ +export const VipIcon = ( { size = 32 }: { size?: number } ) => ( + + { /* VIP Logo - stylized V shape with enterprise cloud feel */ } + + + + +); + +export const VipBadge = ( { className }: { className?: string } ) => ( + + VIP + +); diff --git a/src/modules/add-site/hooks/use-stepper.ts b/src/modules/add-site/hooks/use-stepper.ts index e3f7241107..9c67e800c9 100644 --- a/src/modules/add-site/hooks/use-stepper.ts +++ b/src/modules/add-site/hooks/use-stepper.ts @@ -20,11 +20,13 @@ interface StepperConfig { onBackupContinue?: () => void; onPullRemoteContinue?: () => void; onCreateSubmit?: ( event: FormEvent ) => void; + onVipSubmit?: ( event: FormEvent ) => void; canSubmitBlueprint?: boolean; canSubmitBlueprintDeeplink?: boolean; canSubmitBackup?: boolean; canSubmitPullRemote?: boolean; canSubmitCreate?: boolean; + canSubmitVip?: boolean; } interface StepperContext { @@ -68,6 +70,10 @@ export function useStepper( config?: StepperConfig ): UseStepper { { id: 'site-details', label: __( 'Site name & details' ), path: '/pullRemote/create' }, ]; + const vipSteps: StepperStep[] = [ + { id: 'vip-config', label: __( 'VIP environment settings' ), path: '/vip' }, + ]; + const blueprintDeeplinkSteps: StepperStep[] = [ { id: 'blueprint-selected', label: __( 'Blueprint details' ), path: '/blueprint/deeplink' }, { @@ -112,6 +118,13 @@ export function useStepper( config?: StepperConfig ): UseStepper { }; } + if ( location.path === '/vip' ) { + return { + flow: 'vip', + steps: vipSteps, + }; + } + return null; }, [ location.path, __ ] ); @@ -183,6 +196,11 @@ export function useStepper( config?: StepperConfig ): UseStepper { label: __( 'Add site' ), isVisible: true, }; + case '/vip': + return { + label: __( 'Create VIP site' ), + isVisible: true, + }; default: return undefined; } @@ -212,6 +230,9 @@ export function useStepper( config?: StepperConfig ): UseStepper { case '/pullRemote/create': config?.onCreateSubmit?.( { preventDefault: () => {} } as FormEvent ); break; + case '/vip': + config?.onVipSubmit?.( { preventDefault: () => {} } as FormEvent ); + break; } }, [ location.path, config ] ); @@ -234,6 +255,8 @@ export function useStepper( config?: StepperConfig ): UseStepper { case '/backup/create': case '/pullRemote/create': return config?.canSubmitCreate ?? false; + case '/vip': + return config?.canSubmitVip ?? false; default: return false; } diff --git a/src/modules/add-site/index.tsx b/src/modules/add-site/index.tsx index 11ceed6c97..4c72e0970d 100644 --- a/src/modules/add-site/index.tsx +++ b/src/modules/add-site/index.tsx @@ -24,6 +24,7 @@ import { useGetBlueprints, Blueprint } from 'src/stores/wpcom-api'; import BlueprintDeeplink from './components/blueprint-deeplink'; import { AddSiteBlueprintSelector } from './components/blueprints'; import CreateSite from './components/create-site'; +import CreateVipSite, { type CreateVipSiteFormValues } from './components/create-vip-site'; import ImportBackup from './components/import-backup'; import AddSiteOptions, { type AddSiteFlowType } from './components/options'; import { PullRemoteSite } from './components/pull-remote-site'; @@ -72,9 +73,13 @@ interface NavigationContentProps { setIsDeeplinkFlow: ( isDeeplink: boolean ) => void; } -function NavigationContent( props: NavigationContentProps ) { +function NavigationContent( + props: NavigationContentProps & { onVipSubmit: ( values: CreateVipSiteFormValues ) => void } +) { const { __ } = useI18n(); const { goTo, location } = useNavigator(); + const [ isVipFormValid, setIsVipFormValid ] = useState( false ); + const vipFormRef = useRef< HTMLFormElement >( null ); const { blueprintsData, isLoadingBlueprints, @@ -116,6 +121,8 @@ function NavigationContent( props: NavigationContentProps ) { goTo( '/backup' ); } else if ( option === 'pullRemote' ) { goTo( '/pullRemote' ); + } else if ( option === 'vip' ) { + goTo( '/vip' ); } }, [ goTo ] @@ -175,7 +182,8 @@ function NavigationContent( props: NavigationContentProps ) { location.path === '/blueprint/select' || location.path === '/blueprint/deeplink' || location.path === '/create' || - location.path === '/pullRemote' + location.path === '/pullRemote' || + location.path === '/vip' ) { if ( location.path === '/backup' ) { setFileForImport( null ); @@ -322,6 +330,13 @@ function NavigationContent( props: NavigationContentProps ) { defaultValues={ { ...defaultValues, siteName: remoteSiteName } } /> + + + { formRef.current?.requestSubmit(); } } + onVipSubmit={ () => { + vipFormRef.current?.requestSubmit(); + } } canSubmitBlueprint={ !! selectedBlueprint } canSubmitBlueprintDeeplink={ !! selectedBlueprint } canSubmitBackup={ !! fileForImport } canSubmitPullRemote={ !! selectedRemoteSite } canSubmitCreate={ canSubmit } + canSubmitVip={ isVipFormValid } /> ); @@ -461,6 +480,53 @@ export function AddSiteModalContent( { [ __, handleCreateSite, onSubmit ] ); + const handleVipFormSubmit = useCallback( + async ( values: CreateVipSiteFormValues ) => { + const vipSiteAddedMessage = sprintf( + // translators: %s is the VIP environment slug. + __( 'VIP environment "%s" is being created.' ), + values.slug + ); + + try { + const result = await getIpcApi().createVipEnv( { + slug: values.slug, + title: values.title || undefined, + phpVersion: values.phpVersion || undefined, + multisite: + values.multisite === 'false' + ? false + : ( values.multisite as 'subdomain' | 'subdirectory' ), + appCodePath: values.appCodePath || undefined, + muPluginsPath: values.muPluginsPath || undefined, + elasticsearch: values.elasticsearch, + phpmyadmin: values.phpmyadmin, + xdebug: values.xdebug, + mailpit: values.mailpit, + photon: values.photon, + cron: values.cron, + mediaRedirectDomain: values.mediaRedirectDomain || undefined, + } ); + + if ( result.success ) { + onSubmit?.(); + speak( vipSiteAddedMessage ); + } else { + getIpcApi().showErrorMessageBox( { + title: __( 'Failed to create VIP environment' ), + message: result.stderr || __( 'An unknown error occurred.' ), + } ); + } + } catch ( error ) { + getIpcApi().showErrorMessageBox( { + title: __( 'Failed to create VIP environment' ), + message: error instanceof Error ? error.message : __( 'An unknown error occurred.' ), + } ); + } + }, + [ __, onSubmit ] + ); + // canSubmit is true if the form is initialized, has a name, and is valid (no errors) const canSubmit = formInitialized && defaultSiteName.trim().length > 0 && isFormValid; @@ -478,6 +544,7 @@ export function AddSiteModalContent( { onSiteNameChange={ generateProposedPath } existingDomainNames={ existingDomainNames } onFormSubmit={ handleFormSubmit } + onVipSubmit={ handleVipFormSubmit } onValidityChange={ setIsFormValid } canSubmit={ canSubmit } fileForImport={ fileForImport } diff --git a/src/modules/vip/components/edit-vip-environment.tsx b/src/modules/vip/components/edit-vip-environment.tsx new file mode 100644 index 0000000000..f93f1f4c11 --- /dev/null +++ b/src/modules/vip/components/edit-vip-environment.tsx @@ -0,0 +1,260 @@ +import { SelectControl } from '@wordpress/components'; +import { useI18n } from '@wordpress/react-i18n'; +import { FormEvent, useCallback, useState } from 'react'; +import Button from 'src/components/button'; +import Modal from 'src/components/modal'; +import { cx } from 'src/lib/cx'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useVipContext } from '../context/vip-context'; +import type { VipEnvironment } from '../types'; + +const PHP_VERSIONS = [ + { label: 'PHP 8.2', value: '8.2' }, + { label: 'PHP 8.3', value: '8.3' }, + { label: 'PHP 8.4', value: '8.4' }, +]; + +const WP_VERSIONS = [ + { label: 'Latest', value: 'latest' }, + { label: '6.7', value: '6.7' }, + { label: '6.6', value: '6.6' }, + { label: '6.5', value: '6.5' }, + { label: '6.4', value: '6.4' }, +]; + +interface EditVipEnvironmentProps { + environment: VipEnvironment; +} + +export function EditVipEnvironment( { environment }: EditVipEnvironmentProps ) { + const { __ } = useI18n(); + const { refresh } = useVipContext(); + const [ isOpen, setIsOpen ] = useState( false ); + const [ isUpdating, setIsUpdating ] = useState( false ); + const [ error, setError ] = useState< string | null >( null ); + + // Form state + const [ phpVersion, setPhpVersion ] = useState( environment.phpVersion || '8.2' ); + const [ wordpressVersion, setWordpressVersion ] = useState( + environment.wordpressVersion || 'latest' + ); + const [ elasticsearch, setElasticsearch ] = useState( environment.elasticsearch ); + const [ phpmyadmin, setPhpmyadmin ] = useState( environment.phpmyadmin ); + const [ xdebug, setXdebug ] = useState( environment.xdebug ); + const [ mailpit, setMailpit ] = useState( environment.mailpit ); + + const hasChanges = + phpVersion !== environment.phpVersion || + wordpressVersion !== environment.wordpressVersion || + elasticsearch !== environment.elasticsearch || + phpmyadmin !== environment.phpmyadmin || + xdebug !== environment.xdebug || + mailpit !== environment.mailpit; + + const resetForm = useCallback( () => { + setPhpVersion( environment.phpVersion || '8.2' ); + setWordpressVersion( environment.wordpressVersion || 'latest' ); + setElasticsearch( environment.elasticsearch ); + setPhpmyadmin( environment.phpmyadmin ); + setXdebug( environment.xdebug ); + setMailpit( environment.mailpit ); + setError( null ); + }, [ environment ] ); + + const openModal = () => { + resetForm(); + setIsOpen( true ); + }; + + const closeModal = useCallback( () => { + if ( isUpdating ) { + return; + } + setIsOpen( false ); + }, [ isUpdating ] ); + + const handleSubmit = async ( event: FormEvent ) => { + event.preventDefault(); + setIsUpdating( true ); + setError( null ); + + try { + const args = [ 'dev-env', 'update', `--slug=${ environment.slug }` ]; + + if ( phpVersion !== environment.phpVersion ) { + args.push( `--php=${ phpVersion }` ); + } + + if ( wordpressVersion !== environment.wordpressVersion ) { + args.push( `--wordpress=${ wordpressVersion }` ); + } + + if ( elasticsearch !== environment.elasticsearch ) { + args.push( `--elasticsearch=${ elasticsearch ? 'y' : 'n' }` ); + } + + if ( phpmyadmin !== environment.phpmyadmin ) { + args.push( `--phpmyadmin=${ phpmyadmin ? 'y' : 'n' }` ); + } + + if ( xdebug !== environment.xdebug ) { + args.push( `--xdebug=${ xdebug ? 'y' : 'n' }` ); + } + + if ( mailpit !== environment.mailpit ) { + args.push( `--mailpit=${ mailpit ? 'y' : 'n' }` ); + } + + args.push( '--yes' ); + + const result = await getIpcApi().executeVipCliCommand( args ); + + if ( result.success ) { + await refresh(); + closeModal(); + } else { + setError( result.stderr || __( 'Failed to update environment settings.' ) ); + } + } catch ( err ) { + setError( err instanceof Error ? err.message : __( 'An unexpected error occurred.' ) ); + } finally { + setIsUpdating( false ); + } + }; + + return ( + <> + + + { isOpen && ( + +
+
+ { error && ( +
+ { error } +
+ ) } + +
+ + + +
+ +
+ { __( 'Services' ) } + + + + + + + + +
+ +
+ { __( + 'Changes to these settings require restarting the environment to take effect.' + ) } +
+
+ +
+ + +
+
+
+ ) } + + ); +} diff --git a/src/modules/vip/components/vip-content-tab-import-export.tsx b/src/modules/vip/components/vip-content-tab-import-export.tsx new file mode 100644 index 0000000000..097c481a96 --- /dev/null +++ b/src/modules/vip/components/vip-content-tab-import-export.tsx @@ -0,0 +1,224 @@ +import { + __experimentalVStack as VStack, + __experimentalText as Text, + Notice, +} from '@wordpress/components'; +import { useI18n } from '@wordpress/react-i18n'; +import { useState } from 'react'; +import Button from 'src/components/button'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import type { VipEnvironment } from '../types'; + +interface VipContentTabImportExportProps { + selectedEnvironment: VipEnvironment; +} + +export function VipContentTabImportExport( { + selectedEnvironment, +}: VipContentTabImportExportProps ) { + const { __ } = useI18n(); + const [ isImporting, setIsImporting ] = useState( false ); + const [ importResult, setImportResult ] = useState< { + success: boolean; + message: string; + } | null >( null ); + + const handleImportSql = async () => { + setIsImporting( true ); + setImportResult( null ); + + try { + if ( ! selectedEnvironment.running ) { + setImportResult( { + success: false, + message: __( 'Please start the environment before importing.' ), + } ); + setIsImporting( false ); + return; + } + + // Open file dialog to select SQL file + const result = await getIpcApi().showOpenFolderDialog( + __( 'Select SQL file to import' ), + selectedEnvironment.path + ); + + if ( ! result?.path ) { + setIsImporting( false ); + return; + } + + // Execute vip dev-env import sql command + const importResult = await getIpcApi().executeVipCliCommand( [ + 'dev-env', + 'import', + 'sql', + result.path, + `--slug=${ selectedEnvironment.slug }`, + '--yes', + ] ); + + if ( importResult.success ) { + setImportResult( { + success: true, + message: __( 'SQL file imported successfully.' ), + } ); + } else { + setImportResult( { + success: false, + message: importResult.stderr || __( 'Failed to import SQL file.' ), + } ); + } + } catch ( error ) { + setImportResult( { + success: false, + message: error instanceof Error ? error.message : __( 'An unexpected error occurred.' ), + } ); + } finally { + setIsImporting( false ); + } + }; + + const handleImportMedia = async () => { + setIsImporting( true ); + setImportResult( null ); + + try { + if ( ! selectedEnvironment.running ) { + setImportResult( { + success: false, + message: __( 'Please start the environment before importing.' ), + } ); + setIsImporting( false ); + return; + } + + // Open folder dialog to select media directory + const result = await getIpcApi().showOpenFolderDialog( + __( 'Select media folder to import' ), + selectedEnvironment.path + ); + + if ( ! result?.path ) { + setIsImporting( false ); + return; + } + + // Execute vip dev-env import media command + const importResult = await getIpcApi().executeVipCliCommand( [ + 'dev-env', + 'import', + 'media', + result.path, + `--slug=${ selectedEnvironment.slug }`, + '--yes', + ] ); + + if ( importResult.success ) { + setImportResult( { + success: true, + message: __( 'Media files imported successfully.' ), + } ); + } else { + setImportResult( { + success: false, + message: importResult.stderr || __( 'Failed to import media files.' ), + } ); + } + } catch ( error ) { + setImportResult( { + success: false, + message: error instanceof Error ? error.message : __( 'An unexpected error occurred.' ), + } ); + } finally { + setIsImporting( false ); + } + }; + + return ( +
+ +
+

{ __( 'Import & Export' ) }

+ + { __( 'Import database or media files to your local VIP environment.' ) } + +
+ + { importResult && ( + setImportResult( null ) } + > + { importResult.message } + + ) } + + { /* Import SQL Section */ } +
+ +
+ { __( 'Import SQL Database' ) } + + { __( + 'Import a SQL file to replace the database in your local environment. This will overwrite existing data.' + ) } + +
+ +
+ + + { ! selectedEnvironment.running && ( + + { __( 'Start the environment to enable import.' ) } + + ) } +
+
+
+ + { /* Import Media Section */ } +
+ +
+ { __( 'Import Media Files' ) } + + { __( + 'Import media files (images, documents, etc.) to the wp-content/uploads directory of your local environment.' + ) } + +
+ +
+ +
+
+
+ +
+ + { __( + 'Tip: You can also use the VIP CLI directly for more advanced import options. Run "vip dev-env import --help" in your terminal.' + ) } + +
+
+
+ ); +} diff --git a/src/modules/vip/components/vip-content-tab-overview.tsx b/src/modules/vip/components/vip-content-tab-overview.tsx new file mode 100644 index 0000000000..221c81cbaf --- /dev/null +++ b/src/modules/vip/components/vip-content-tab-overview.tsx @@ -0,0 +1,309 @@ +import * as Sentry from '@sentry/electron/renderer'; +import { __ } from '@wordpress/i18n'; +import { + archive, + code, + desktop, + navigation, + preformatted, + styles, + symbolFilled, + layout, + page, +} from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import { ArrowIcon } from 'src/components/arrow-icon'; +import { ButtonsSection, ButtonsSectionProps } from 'src/components/buttons-section'; +import { isWindows } from 'src/lib/app-globals'; +import { cx } from 'src/lib/cx'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { supportedEditorConfig } from 'src/modules/user-settings/lib/editor'; +import { getTerminalName } from 'src/modules/user-settings/lib/terminal'; +import { useGetUserEditorQuery, useGetUserTerminalQuery } from 'src/stores/installed-apps-api'; +import { useVipContext } from '../context/vip-context'; +import type { VipEnvironment } from '../types'; + +interface VipContentTabOverviewProps { + selectedEnvironment: VipEnvironment; +} + +function CustomizeSection( { + selectedEnvironment, +}: Pick< VipContentTabOverviewProps, 'selectedEnvironment' > ) { + const { loadingEnvironments, startEnvironment } = useVipContext(); + const isLoading = loadingEnvironments[ selectedEnvironment.slug ] ?? false; + + const handleCustomizeClick = ( path: string ) => async () => { + if ( isLoading ) return; + if ( ! selectedEnvironment.running ) { + await startEnvironment( selectedEnvironment.slug ); + } + if ( selectedEnvironment.urls.length > 0 ) { + const baseUrl = selectedEnvironment.urls[ 0 ]; + let url: string; + if ( selectedEnvironment.autologinKey ) { + const redirectTo = encodeURIComponent( path ); + url = `${ baseUrl }wp-login.php?vip-autologin=${ selectedEnvironment.autologinKey }&redirect_to=${ redirectTo }`; + } else { + url = `${ baseUrl }${ path.startsWith( '/' ) ? path.slice( 1 ) : path }`; + } + getIpcApi().openURL( url ); + } + }; + + // VIP sites typically use block themes, so we provide block theme buttons by default + const blockThemeButtons: ButtonsSectionProps[ 'buttonsArray' ] = [ + { + label: __( 'Site Editor' ), + icon: desktop, + onClick: handleCustomizeClick( '/wp-admin/site-editor.php' ), + disabled: isLoading, + }, + { + label: __( 'Styles' ), + icon: styles, + onClick: handleCustomizeClick( '/wp-admin/site-editor.php?path=%2Fwp_global_styles' ), + disabled: isLoading, + }, + { + label: __( 'Patterns' ), + icon: symbolFilled, + onClick: handleCustomizeClick( '/wp-admin/site-editor.php?path=%2Fpatterns' ), + disabled: isLoading, + }, + { + label: __( 'Navigation' ), + icon: navigation, + onClick: handleCustomizeClick( '/wp-admin/site-editor.php?path=%2Fnavigation' ), + disabled: isLoading, + }, + { + label: __( 'Templates' ), + icon: layout, + onClick: handleCustomizeClick( '/wp-admin/site-editor.php?path=%2Fwp_template' ), + disabled: isLoading, + }, + { + label: __( 'Pages' ), + icon: page, + onClick: handleCustomizeClick( '/wp-admin/site-editor.php?path=%2Fpage' ), + disabled: isLoading, + }, + ]; + + return ; +} + +function ShortcutsSection( { + selectedEnvironment, +}: Pick< VipContentTabOverviewProps, 'selectedEnvironment' > ) { + const { data: editor } = useGetUserEditorQuery(); + const { data: terminal } = useGetUserTerminalQuery(); + + const path = selectedEnvironment.appCodePath || selectedEnvironment.path; + + const buttonsArray: ButtonsSectionProps[ 'buttonsArray' ] = [ + { + label: isWindows() ? __( 'File Explorer' ) : __( 'Finder' ), + className: 'text-nowrap', + icon: archive, + onClick: () => { + if ( path ) { + getIpcApi().openLocalPath( path ); + } + }, + disabled: ! path, + }, + ]; + + const editorConfig = editor ? supportedEditorConfig[ editor ] : false; + if ( editor && editorConfig ) { + buttonsArray.push( { + label: editorConfig.label, + className: 'text-nowrap', + icon: code, + onClick: async () => { + if ( path ) { + await getIpcApi().openAppAtPath( editor, path ); + } + }, + disabled: ! path, + } ); + } + + const terminalName = getTerminalName( terminal ); + buttonsArray.push( { + label: terminalName, + className: 'text-nowrap', + icon: preformatted, + onClick: async () => { + if ( path ) { + try { + await getIpcApi().openTerminalAtPath( path ); + } catch ( error ) { + Sentry.captureException( error ); + alert( __( 'Could not open the terminal.' ) ); + } + } + }, + disabled: ! path, + } ); + + return ; +} + +function ServicesSection( { + selectedEnvironment, +}: Pick< VipContentTabOverviewProps, 'selectedEnvironment' > ) { + const { __ } = useI18n(); + + // phpMyAdmin URL when running + const phpMyAdminUrl = selectedEnvironment.phpmyadmin + ? `http://${ selectedEnvironment.slug }-phpmyadmin.vipdev.lndo.site/` + : null; + + // Mailpit URL when running + const mailpitUrl = selectedEnvironment.mailpit + ? `http://${ selectedEnvironment.slug }-mailpit.vipdev.lndo.site/` + : null; + + const services = [ + { + name: __( 'Elasticsearch' ), + enabled: selectedEnvironment.elasticsearch, + url: null, + }, + { + name: __( 'phpMyAdmin' ), + enabled: selectedEnvironment.phpmyadmin, + url: phpMyAdminUrl, + }, + { + name: __( 'Xdebug' ), + enabled: selectedEnvironment.xdebug, + url: null, + }, + { + name: __( 'Mailpit' ), + enabled: selectedEnvironment.mailpit, + url: mailpitUrl, + }, + ]; + + const enabledServices = services.filter( ( s ) => s.enabled ); + + const handleServiceClick = ( url: string | null ) => { + if ( url && selectedEnvironment.running ) { + getIpcApi().openURL( url ); + } + }; + + if ( enabledServices.length === 0 ) { + return null; + } + + return ( +
+

{ __( 'Services' ) }

+
+ { enabledServices.map( ( service ) => { + const isClickable = service.url && selectedEnvironment.running; + return isClickable ? ( + + ) : ( + + { service.name } + + ); + } ) } +
+
+ ); +} + +export function VipContentTabOverview( { selectedEnvironment }: VipContentTabOverviewProps ) { + const { __ } = useI18n(); + const { loadingEnvironments, startEnvironment } = useVipContext(); + const isLoading = loadingEnvironments[ selectedEnvironment.slug ] ?? false; + + const handleThumbnailClick = async () => { + if ( isLoading ) return; + + if ( ! selectedEnvironment.running ) { + await startEnvironment( selectedEnvironment.slug ); + } + if ( selectedEnvironment.urls.length > 0 ) { + getIpcApi().openURL( selectedEnvironment.urls[ 0 ] ); + } + }; + + return ( +
+ { /* Left column: Site preview / VIP badge */ } +
+

{ __( 'Site' ) }

+
+ +
+
+

+ { __( 'WordPress' ) } { selectedEnvironment.wordpressVersion } +

+
+
+ + { /* Right column: Customize + Shortcuts + Services */ } +
+ + + +
+
+ ); +} diff --git a/src/modules/vip/components/vip-content-tab-settings.tsx b/src/modules/vip/components/vip-content-tab-settings.tsx new file mode 100644 index 0000000000..5fadda42c2 --- /dev/null +++ b/src/modules/vip/components/vip-content-tab-settings.tsx @@ -0,0 +1,219 @@ +import { DropdownMenu, MenuGroup } from '@wordpress/components'; +import { moreVertical } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import { PropsWithChildren, useState } from 'react'; +import Button from 'src/components/button'; +import { CopyTextButton } from 'src/components/copy-text-button'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useVipContext } from '../context/vip-context'; +import { EditVipEnvironment } from './edit-vip-environment'; +import type { VipEnvironment } from '../types'; + +interface VipContentTabSettingsProps { + selectedEnvironment: VipEnvironment; +} + +function SettingsRow( { children, label }: PropsWithChildren< { label: string } > ) { + return ( + + + { label } + + { children } + + ); +} + +function DeleteVipSite( { + environment, + onClose, +}: { + environment: VipEnvironment; + onClose: () => void; +} ) { + const { __ } = useI18n(); + const { refresh } = useVipContext(); + const [ isDestroying, setIsDestroying ] = useState( false ); + + const handleDestroy = async () => { + const CANCEL_BUTTON_INDEX = 0; + const DESTROY_BUTTON_INDEX = 1; + + const { response } = await getIpcApi().showMessageBox( { + type: 'warning', + message: __( 'Are you sure you want to delete this site?' ), + detail: __( + 'This will permanently delete the environment and all its data. This action cannot be undone.' + ), + buttons: [ __( 'Cancel' ), __( 'Delete' ) ], + cancelId: CANCEL_BUTTON_INDEX, + } ); + + if ( response !== DESTROY_BUTTON_INDEX ) { + onClose(); + return; + } + + setIsDestroying( true ); + + try { + // Stop the environment first if it's running + if ( environment.running ) { + await getIpcApi().executeVipCliCommand( [ + 'dev-env', + 'stop', + `--slug=${ environment.slug }`, + ] ); + } + + await getIpcApi().executeVipCliCommand( [ + 'dev-env', + 'destroy', + `--slug=${ environment.slug }`, + '--yes', + ] ); + + await refresh(); + } finally { + setIsDestroying( false ); + onClose(); + } + }; + + return ( + + ); +} + +export function VipContentTabSettings( { selectedEnvironment }: VipContentTabSettingsProps ) { + const { __ } = useI18n(); + + const siteUrl = + selectedEnvironment.urls.length > 0 + ? selectedEnvironment.urls[ 0 ] + : `http://${ selectedEnvironment.slug }.vipdev.lndo.site/`; + + const domain = siteUrl.replace( /^https?:\/\//, '' ).replace( /\/$/, '' ); + + return ( +
+
+

+ { __( 'Site details' ) } +

+
+ + + { ( { onClose }: { onClose: () => void } ) => ( + + + + ) } + +
+
+ + + +
+ + { selectedEnvironment.title || selectedEnvironment.slug } + +
+
+ + + { domain } + + + + + { selectedEnvironment.path } + + + { selectedEnvironment.appCodePath && ( + + + { selectedEnvironment.appCodePath } + + + ) } + + { selectedEnvironment.wordpressVersion || __( 'Unknown' ) } + + +
+ + { selectedEnvironment.phpVersion || __( 'Unknown' ) } + +
+
+ + { selectedEnvironment.multisite === false + ? __( 'No' ) + : selectedEnvironment.multisite === 'subdomain' + ? __( 'Subdomain' ) + : __( 'Subdirectory' ) } + + + + + + + + vipgo + + + + + ************ + + + + + { `${ domain }/wp-admin` } + + + +
+

{ __( 'WP Admin' ) }

+
+
+ ); +} diff --git a/src/modules/vip/components/vip-content-tab-sync.tsx b/src/modules/vip/components/vip-content-tab-sync.tsx new file mode 100644 index 0000000000..1e91fd716a --- /dev/null +++ b/src/modules/vip/components/vip-content-tab-sync.tsx @@ -0,0 +1,134 @@ +import { + __experimentalVStack as VStack, + __experimentalText as Text, + Notice, +} from '@wordpress/components'; +import { useI18n } from '@wordpress/react-i18n'; +import { useState } from 'react'; +import Button from 'src/components/button'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import type { VipEnvironment } from '../types'; + +interface VipContentTabSyncProps { + selectedEnvironment: VipEnvironment; +} + +export function VipContentTabSync( { selectedEnvironment }: VipContentTabSyncProps ) { + const { __ } = useI18n(); + const [ isSyncing, setIsSyncing ] = useState( false ); + const [ syncResult, setSyncResult ] = useState< { success: boolean; message: string } | null >( + null + ); + + const handleSyncFromVip = async () => { + setIsSyncing( true ); + setSyncResult( null ); + + try { + // The sync command requires the environment to be running + if ( ! selectedEnvironment.running ) { + setSyncResult( { + success: false, + message: __( 'Please start the environment before syncing.' ), + } ); + setIsSyncing( false ); + return; + } + + // Execute vip dev-env sync sql command + const result = await getIpcApi().executeVipCliCommand( [ + 'dev-env', + 'sync', + 'sql', + `--slug=${ selectedEnvironment.slug }`, + '--yes', + ] ); + + if ( result.success ) { + setSyncResult( { + success: true, + message: __( 'Database synced successfully from VIP Platform.' ), + } ); + } else { + setSyncResult( { + success: false, + message: + result.stderr || + __( + 'Failed to sync database. Make sure you are logged in to VIP CLI and have access to the application.' + ), + } ); + } + } catch ( error ) { + setSyncResult( { + success: false, + message: error instanceof Error ? error.message : __( 'An unexpected error occurred.' ), + } ); + } finally { + setIsSyncing( false ); + } + }; + + return ( +
+ +
+

{ __( 'Sync from VIP Platform' ) }

+ + { __( + 'Pull the database from your VIP Platform environment to this local environment. This will replace your local database with production data.' + ) } + +
+ + { syncResult && ( + setSyncResult( null ) } + > + { syncResult.message } + + ) } + +
+ +
+ { __( 'Database Sync' ) } + + { __( + 'Syncs the database from your connected VIP Platform environment. Requires VIP CLI authentication.' + ) } + +
+ +
+ + + { ! selectedEnvironment.running && ( + + { __( 'Start the environment to enable sync.' ) } + + ) } +
+
+
+ +
+ + { __( + 'Note: Make sure you are logged in to VIP CLI (run "vip login" in terminal) and have access to the VIP application you want to sync from.' + ) } + +
+
+
+ ); +} diff --git a/src/modules/vip/components/vip-content-tabs.tsx b/src/modules/vip/components/vip-content-tabs.tsx new file mode 100644 index 0000000000..6ce9ac0c9f --- /dev/null +++ b/src/modules/vip/components/vip-content-tabs.tsx @@ -0,0 +1,82 @@ +import { TabPanel } from '@wordpress/components'; +import { useI18n } from '@wordpress/react-i18n'; +import { useState } from 'react'; +import { MIN_WIDTH_CLASS_TO_MEASURE } from 'src/constants'; +import { useVipContext } from '../context/vip-context'; +import { VipContentTabImportExport } from './vip-content-tab-import-export'; +import { VipContentTabOverview } from './vip-content-tab-overview'; +import { VipContentTabSettings } from './vip-content-tab-settings'; +import { VipContentTabSync } from './vip-content-tab-sync'; +import { VipHeader } from './vip-header'; + +type VipTabName = 'overview' | 'sync' | 'import-export' | 'settings'; + +export function VipContentTabs() { + const { __ } = useI18n(); + const { selectedEnvironment } = useVipContext(); + const [ selectedTab, setSelectedTab ] = useState< VipTabName >( 'overview' ); + + const tabs = [ + { + name: 'overview' as const, + title: __( 'Overview' ), + }, + { + name: 'sync' as const, + title: __( 'Sync' ), + }, + { + name: 'import-export' as const, + title: __( 'Import / Export' ), + }, + { + name: 'settings' as const, + title: __( 'Settings' ), + }, + ]; + + if ( ! selectedEnvironment ) { + return ( +
+

{ __( 'Select a site to view details.' ) }

+
+ ); + } + + return ( +
+ + { + setSelectedTab( tabName as VipTabName ); + } } + initialTabName={ selectedTab } + key={ selectedEnvironment.slug } + > + { ( { name } ) => ( +
+ { name === 'overview' && ( + + ) } + { name === 'sync' && } + { name === 'import-export' && ( + + ) } + { name === 'settings' && ( + + ) } +
+ ) } +
+
+ ); +} diff --git a/src/modules/vip/components/vip-header.tsx b/src/modules/vip/components/vip-header.tsx new file mode 100644 index 0000000000..89cf854954 --- /dev/null +++ b/src/modules/vip/components/vip-header.tsx @@ -0,0 +1,117 @@ +import { Notice } from '@wordpress/components'; +import { useI18n } from '@wordpress/react-i18n'; +import { ActionButton } from 'src/components/action-button'; +import { ArrowIcon } from 'src/components/arrow-icon'; +import Button from 'src/components/button'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useVipContext } from '../context/vip-context'; + +export function VipHeader() { + const { __ } = useI18n(); + const { + selectedEnvironment, + loadingEnvironments, + startEnvironment, + stopEnvironment, + operationError, + clearOperationError, + } = useVipContext(); + + if ( ! selectedEnvironment ) { + return null; + } + + const isLoading = loadingEnvironments[ selectedEnvironment.slug ] ?? false; + + const handleWpAdminClick = async () => { + if ( isLoading ) return; + + if ( ! selectedEnvironment.running ) { + await startEnvironment( selectedEnvironment.slug ); + } + + if ( selectedEnvironment.urls.length > 0 ) { + const adminUrl = selectedEnvironment.autologinKey + ? `${ selectedEnvironment.urls[ 0 ] }wp-login.php?vip-autologin=${ selectedEnvironment.autologinKey }` + : `${ selectedEnvironment.urls[ 0 ] }wp-admin/`; + getIpcApi().openURL( adminUrl ); + } + }; + + const handleOpenSiteClick = async () => { + if ( isLoading ) return; + + if ( ! selectedEnvironment.running ) { + await startEnvironment( selectedEnvironment.slug ); + } + + if ( selectedEnvironment.urls.length > 0 ) { + getIpcApi().openURL( selectedEnvironment.urls[ 0 ] ); + } + }; + + const handleStart = async () => { + await startEnvironment( selectedEnvironment.slug ); + }; + + const handleStop = async () => { + await stopEnvironment( selectedEnvironment.slug ); + }; + + return ( +
+ { operationError && ( +
+ + { operationError } + +
+ ) } +
+
+

+ { selectedEnvironment.title || selectedEnvironment.slug } +

+
+ + +
+
+
+ { + if ( selectedEnvironment.running ) { + handleStop().catch( () => {} ); + } else { + handleStart().catch( () => {} ); + } + } } + disabled={ false } + buttonLabelOnDisabled="" + /> +
+
+
+ ); +} diff --git a/src/modules/vip/components/vip-site-menu.tsx b/src/modules/vip/components/vip-site-menu.tsx new file mode 100644 index 0000000000..95137053ba --- /dev/null +++ b/src/modules/vip/components/vip-site-menu.tsx @@ -0,0 +1,190 @@ +import { Spinner } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import React from 'react'; +import { Tooltip } from 'src/components/tooltip'; +import { isMac } from 'src/lib/app-globals'; +import { cx } from 'src/lib/cx'; +import { VipBadge } from 'src/modules/add-site/components/vip-icon'; +import { useVipContext } from '../context/vip-context'; +import type { VipEnvironment } from '../types'; + +interface VipSiteItemProps { + environment: VipEnvironment; + isSelected: boolean; + isLoading: boolean; + onSelect: () => void; + onStart: () => Promise< boolean >; + onStop: () => Promise< boolean >; +} + +function VipSiteItem( { + environment, + isSelected, + isLoading, + onSelect, + onStart, + onStop, +}: VipSiteItemProps ) { + const handleToggle = async ( e: React.MouseEvent ) => { + e.stopPropagation(); + if ( environment.running ) { + await onStop(); + } else { + await onStart(); + } + }; + + const classCircle = `rounded-full`; + const triangle = ( + + + + ); + + const rectangle = ( + + + + ); + + const tooltipText = isLoading + ? __( 'Loading...' ) + : environment.running + ? __( 'Stop VIP environment' ) + : __( 'Start VIP environment' ); + + return ( +
  • + + { isLoading ? ( + +
    + +
    +
    + ) : ( + + + + ) } +
  • + ); +} + +export default function VipSiteMenu() { + const { + environments, + selectedEnvironment, + isLoading, + isVipAvailable, + loadingEnvironments, + selectEnvironment, + startEnvironment, + stopEnvironment, + } = useVipContext(); + + const handleSelectEnvironment = ( slug: string ) => { + selectEnvironment( slug ); + }; + + // Don't show anything if VIP CLI is not available + if ( ! isVipAvailable && ! isLoading ) { + return null; + } + + // Don't show anything if no VIP environments exist + if ( ! isLoading && environments.length === 0 ) { + return null; + } + + return ( +
    +
    + { __( 'VIP Environments' ) } +
    + { isLoading ? ( +
    + +
    + ) : ( +
      + { environments.map( ( env ) => ( + handleSelectEnvironment( env.slug ) } + onStart={ () => startEnvironment( env.slug ) } + onStop={ () => stopEnvironment( env.slug ) } + /> + ) ) } +
    + ) } +
    + ); +} diff --git a/src/modules/vip/context/vip-context.tsx b/src/modules/vip/context/vip-context.tsx new file mode 100644 index 0000000000..12c5e0db5f --- /dev/null +++ b/src/modules/vip/context/vip-context.tsx @@ -0,0 +1,166 @@ +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import type { VipEnvironment, VipCliStatus } from '../types'; + +interface VipContextValue { + environments: VipEnvironment[]; + selectedEnvironment: VipEnvironment | null; + isLoading: boolean; + error: string | null; + isVipAvailable: boolean; + vipCliStatus: VipCliStatus | null; + loadingEnvironments: Record< string, boolean >; + operationError: string | null; + clearOperationError: () => void; + selectEnvironment: ( slug: string | null ) => void; + refresh: () => Promise< void >; + startEnvironment: ( slug: string ) => Promise< boolean >; + stopEnvironment: ( slug: string ) => Promise< boolean >; +} + +const VipContext = createContext< VipContextValue | null >( null ); + +export function VipProvider( { children }: { children: ReactNode } ) { + const [ environments, setEnvironments ] = useState< VipEnvironment[] >( [] ); + const [ selectedSlug, setSelectedSlug ] = useState< string | null >( null ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ error, setError ] = useState< string | null >( null ); + const [ isVipAvailable, setIsVipAvailable ] = useState( false ); + const [ vipCliStatus, setVipCliStatus ] = useState< VipCliStatus | null >( null ); + const [ loadingEnvironments, setLoadingEnvironments ] = useState< Record< string, boolean > >( + {} + ); + const [ operationError, setOperationError ] = useState< string | null >( null ); + + const clearOperationError = useCallback( () => { + setOperationError( null ); + }, [] ); + + const refresh = useCallback( async () => { + setIsLoading( true ); + setError( null ); + + try { + const cliStatus = await getIpcApi().isVipCliAvailable(); + setVipCliStatus( cliStatus ); + setIsVipAvailable( cliStatus.available ); + + if ( ! cliStatus.available ) { + setEnvironments( [] ); + setIsLoading( false ); + return; + } + + const envs = await getIpcApi().getVipEnvironments(); + setEnvironments( envs ); + } catch ( err ) { + setError( err instanceof Error ? err.message : 'Failed to load VIP environments' ); + setEnvironments( [] ); + } finally { + setIsLoading( false ); + } + }, [] ); + + const selectEnvironment = useCallback( ( slug: string | null ) => { + setSelectedSlug( slug ); + }, [] ); + + const startEnvironment = useCallback( + async ( slug: string ): Promise< boolean > => { + setLoadingEnvironments( ( prev ) => ( { ...prev, [ slug ]: true } ) ); + setOperationError( null ); + try { + const result = await getIpcApi().startVipEnv( slug ); + if ( result.success ) { + await refresh(); + return true; + } + const errorMessage = result.stderr || result.stdout || 'Failed to start environment'; + setOperationError( errorMessage ); + return false; + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred'; + setOperationError( errorMessage ); + return false; + } finally { + setLoadingEnvironments( ( prev ) => ( { ...prev, [ slug ]: false } ) ); + } + }, + [ refresh ] + ); + + const stopEnvironment = useCallback( + async ( slug: string ): Promise< boolean > => { + setLoadingEnvironments( ( prev ) => ( { ...prev, [ slug ]: true } ) ); + setOperationError( null ); + try { + const result = await getIpcApi().stopVipEnv( slug ); + if ( result.success ) { + await refresh(); + return true; + } + const errorMessage = result.stderr || result.stdout || 'Failed to stop environment'; + setOperationError( errorMessage ); + return false; + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred'; + setOperationError( errorMessage ); + return false; + } finally { + setLoadingEnvironments( ( prev ) => ( { ...prev, [ slug ]: false } ) ); + } + }, + [ refresh ] + ); + + useEffect( () => { + void refresh(); + }, [ refresh ] ); + + const selectedEnvironment = environments.find( ( env ) => env.slug === selectedSlug ) ?? null; + + return ( + + { children } + + ); +} + +// Default context value for when used outside of VipProvider (e.g., in tests) +const defaultVipContext: VipContextValue = { + environments: [], + selectedEnvironment: null, + isLoading: false, + error: null, + isVipAvailable: false, + vipCliStatus: null, + loadingEnvironments: {}, + operationError: null, + clearOperationError: () => {}, + selectEnvironment: () => {}, + refresh: async () => {}, + startEnvironment: async () => false, + stopEnvironment: async () => false, +}; + +export function useVipContext(): VipContextValue { + const context = useContext( VipContext ); + // Return default context if not in a VipProvider (e.g., in tests) + return context ?? defaultVipContext; +} diff --git a/src/modules/vip/hooks/use-vip-environments.ts b/src/modules/vip/hooks/use-vip-environments.ts new file mode 100644 index 0000000000..ecbe5be5b2 --- /dev/null +++ b/src/modules/vip/hooks/use-vip-environments.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useState } from 'react'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import type { VipEnvironment, VipCliStatus } from '../types'; + +interface UseVipEnvironments { + environments: VipEnvironment[]; + isLoading: boolean; + error: string | null; + isVipAvailable: boolean; + vipCliStatus: VipCliStatus | null; + refresh: () => Promise< void >; + startEnvironment: ( slug: string ) => Promise< boolean >; + stopEnvironment: ( slug: string ) => Promise< boolean >; +} + +export function useVipEnvironments(): UseVipEnvironments { + const [ environments, setEnvironments ] = useState< VipEnvironment[] >( [] ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ error, setError ] = useState< string | null >( null ); + const [ isVipAvailable, setIsVipAvailable ] = useState( false ); + const [ vipCliStatus, setVipCliStatus ] = useState< VipCliStatus | null >( null ); + + const refresh = useCallback( async () => { + setIsLoading( true ); + setError( null ); + + try { + // First check if VIP CLI is available + const cliStatus = await getIpcApi().isVipCliAvailable(); + setVipCliStatus( cliStatus ); + setIsVipAvailable( cliStatus.available ); + + if ( ! cliStatus.available ) { + setEnvironments( [] ); + setIsLoading( false ); + return; + } + + // Get VIP environments + const envs = await getIpcApi().getVipEnvironments(); + setEnvironments( envs ); + } catch ( err ) { + setError( err instanceof Error ? err.message : 'Failed to load VIP environments' ); + setEnvironments( [] ); + } finally { + setIsLoading( false ); + } + }, [] ); + + const startEnvironment = useCallback( + async ( slug: string ): Promise< boolean > => { + try { + const result = await getIpcApi().startVipEnv( slug ); + if ( result.success ) { + // Refresh to get updated status + await refresh(); + return true; + } + return false; + } catch { + return false; + } + }, + [ refresh ] + ); + + const stopEnvironment = useCallback( + async ( slug: string ): Promise< boolean > => { + try { + const result = await getIpcApi().stopVipEnv( slug ); + if ( result.success ) { + // Refresh to get updated status + await refresh(); + return true; + } + return false; + } catch { + return false; + } + }, + [ refresh ] + ); + + // Load environments on mount + useEffect( () => { + void refresh(); + }, [ refresh ] ); + + return { + environments, + isLoading, + error, + isVipAvailable, + vipCliStatus, + refresh, + startEnvironment, + stopEnvironment, + }; +} diff --git a/src/modules/vip/lib/ipc-handlers.ts b/src/modules/vip/lib/ipc-handlers.ts new file mode 100644 index 0000000000..4d5e3d2d52 --- /dev/null +++ b/src/modules/vip/lib/ipc-handlers.ts @@ -0,0 +1,147 @@ +/** + * IPC handlers for VIP Local Development Environment integration. + * + * These handlers are exported and re-exported from the main ipc-handlers.ts file. + */ + +import { shell } from 'electron'; +import { + checkVipCliStatus, + createVipEnvironment, + executeVipCommand, + getVipDevEnvPath, + getVipEnvironment, + listVipEnvironments, + startVipEnvironment, + stopVipEnvironment, +} from './vip-environment'; +import type { + VipCliStatus, + VipCommandResult, + VipCreateOptions, + VipEnvironment, + VipStartOptions, +} from '../types'; +import type { IpcMainInvokeEvent } from 'electron'; + +/** + * Check if VIP CLI is available on the system. + */ +export async function isVipCliAvailable( _event: IpcMainInvokeEvent ): Promise< VipCliStatus > { + return checkVipCliStatus(); +} + +/** + * List all VIP Local Development Environments. + */ +export async function getVipEnvironments( + _event: IpcMainInvokeEvent +): Promise< VipEnvironment[] > { + return listVipEnvironments(); +} + +/** + * Get details for a specific VIP environment. + */ +export async function getVipEnvironmentDetails( + _event: IpcMainInvokeEvent, + slug: string +): Promise< VipEnvironment | null > { + return getVipEnvironment( slug ); +} + +/** + * Start a VIP environment. + */ +export async function startVipEnv( + _event: IpcMainInvokeEvent, + slug: string, + options?: VipStartOptions +): Promise< VipCommandResult > { + return startVipEnvironment( slug, options ); +} + +/** + * Stop a VIP environment. + */ +export async function stopVipEnv( + _event: IpcMainInvokeEvent, + slug: string +): Promise< VipCommandResult > { + return stopVipEnvironment( slug ); +} + +/** + * Execute a VIP CLI command. + */ +export async function executeVipCliCommand( + _event: IpcMainInvokeEvent, + args: string[] +): Promise< VipCommandResult > { + return executeVipCommand( args ); +} + +/** + * Open the VIP environment directory in the file explorer. + */ +export async function openVipEnvironmentFolder( + _event: IpcMainInvokeEvent, + slug: string +): Promise< void > { + const env = await getVipEnvironment( slug ); + if ( env ) { + await shell.openPath( env.path ); + } +} + +/** + * Open the VIP environment's app code directory in the file explorer. + */ +export async function openVipAppCodeFolder( + _event: IpcMainInvokeEvent, + slug: string +): Promise< void > { + const env = await getVipEnvironment( slug ); + if ( env?.appCodePath ) { + await shell.openPath( env.appCodePath ); + } +} + +/** + * Open the VIP environment in the browser. + */ +export async function openVipEnvironmentInBrowser( + _event: IpcMainInvokeEvent, + slug: string, + useAutologin = true +): Promise< void > { + const env = await getVipEnvironment( slug ); + if ( env && env.urls.length > 0 ) { + let url = env.urls[ 0 ]; + + // Add autologin parameter if available and requested + if ( useAutologin && env.autologinKey ) { + const adminUrl = url.endsWith( '/' ) ? `${ url }wp-admin/` : `${ url }/wp-admin/`; + url = `${ adminUrl }?vip-dev-autologin=${ env.autologinKey }`; + } + + await shell.openExternal( url ); + } +} + +/** + * Get the path to the VIP dev-environment directory. + */ +export function getVipDataPath( _event: IpcMainInvokeEvent ): string { + return getVipDevEnvPath(); +} + +/** + * Create a new VIP environment. + */ +export async function createVipEnv( + _event: IpcMainInvokeEvent, + options: VipCreateOptions +): Promise< VipCommandResult > { + return createVipEnvironment( options ); +} diff --git a/src/modules/vip/lib/tests/vip-environment.test.ts b/src/modules/vip/lib/tests/vip-environment.test.ts new file mode 100644 index 0000000000..195a034d6a --- /dev/null +++ b/src/modules/vip/lib/tests/vip-environment.test.ts @@ -0,0 +1,173 @@ +/** + * @jest-environment node + */ +import fs from 'fs'; +import fsPromises from 'fs/promises'; + +// Store original platform +const originalPlatform = process.platform; + +jest.mock( 'fs', () => ( { + existsSync: jest.fn(), +} ) ); + +jest.mock( 'fs/promises', () => ( { + readFile: jest.fn(), + readdir: jest.fn(), +} ) ); + +// Mock child_process exec with promisify-compatible implementation +jest.mock( 'child_process', () => ( { + exec: jest.fn(), +} ) ); + +// Mock instance data structure for reference (used in documentation) + +const _mockInstanceDataShape = { + siteSlug: 'string', + wpTitle: 'string', + multisite: 'boolean', + wordpress: { mode: 'image', tag: 'string' }, + muPlugins: { mode: 'image' }, + appCode: { mode: 'local', dir: 'string' }, + php: 'string (image tag)', + elasticsearch: 'boolean', + phpmyadmin: 'boolean', + xdebug: 'boolean', + mailpit: 'boolean', + autologinKey: 'string', + adminPassword: 'string', +}; + +describe( 'vip-environment', () => { + beforeEach( () => { + jest.resetAllMocks(); + jest.resetModules(); + // Reset platform + Object.defineProperty( process, 'platform', { value: originalPlatform, writable: true } ); + } ); + + afterAll( () => { + // Restore original platform + Object.defineProperty( process, 'platform', { value: originalPlatform, writable: true } ); + } ); + + describe( 'getVipDevEnvPath', () => { + it( 'should return path containing vip/dev-environment', () => { + const { getVipDevEnvPath } = require( '../vip-environment' ); + const result = getVipDevEnvPath(); + expect( result ).toContain( 'vip' ); + expect( result ).toContain( 'dev-environment' ); + } ); + + it( 'should use XDG_DATA_HOME when set on non-Windows', () => { + Object.defineProperty( process, 'platform', { value: 'darwin', writable: true } ); + process.env.XDG_DATA_HOME = '/custom/data'; + + jest.resetModules(); + const { getVipDevEnvPath } = require( '../vip-environment' ); + const result = getVipDevEnvPath(); + + expect( result ).toBe( '/custom/data/vip/dev-environment' ); + + delete process.env.XDG_DATA_HOME; + } ); + } ); + + describe( 'listVipEnvironments', () => { + it( 'should return empty array when dev-environment directory does not exist', async () => { + ( fs.existsSync as jest.Mock ).mockReturnValue( false ); + + const { listVipEnvironments } = require( '../vip-environment' ); + const result = await listVipEnvironments(); + + expect( result ).toEqual( [] ); + } ); + + it( 'should skip non-directory entries', async () => { + ( fs.existsSync as jest.Mock ).mockReturnValue( true ); + ( fsPromises.readdir as jest.Mock ).mockResolvedValue( [ + { name: 'some-file.txt', isDirectory: () => false }, + ] ); + + const { listVipEnvironments } = require( '../vip-environment' ); + const result = await listVipEnvironments(); + + expect( result ).toEqual( [] ); + } ); + + it( 'should skip directories without valid instance_data.json', async () => { + ( fs.existsSync as jest.Mock ).mockReturnValue( true ); + ( fsPromises.readdir as jest.Mock ).mockResolvedValue( [ + { name: 'invalid-site', isDirectory: () => true }, + ] ); + ( fsPromises.readFile as jest.Mock ).mockRejectedValue( new Error( 'File not found' ) ); + + const { listVipEnvironments } = require( '../vip-environment' ); + const result = await listVipEnvironments(); + + expect( result ).toEqual( [] ); + } ); + } ); + + describe( 'getVipEnvironment', () => { + it( 'should return null when environment does not exist', async () => { + ( fsPromises.readFile as jest.Mock ).mockRejectedValue( new Error( 'File not found' ) ); + + const { getVipEnvironment } = require( '../vip-environment' ); + const result = await getVipEnvironment( 'nonexistent' ); + + expect( result ).toBeNull(); + } ); + } ); +} ); + +describe( 'VipEnvironment type mapping', () => { + it( 'should correctly map instance data fields to VipEnvironment', () => { + // Test the expected shape of the VipEnvironment interface + const expectedFields = [ + 'slug', + 'title', + 'running', + 'phpVersion', + 'wordpressVersion', + 'multisite', + 'elasticsearch', + 'phpmyadmin', + 'xdebug', + 'mailpit', + 'path', + 'urls', + ]; + + // This test validates that the interface contract is maintained + expectedFields.forEach( ( field ) => { + expect( typeof field ).toBe( 'string' ); + } ); + } ); +} ); + +describe( 'PHP version extraction', () => { + // Helper function to extract PHP version from image tag + const extractPhpVersion = ( phpImageTag: string ): string => { + const match = phpImageTag.match( /:(\d+\.\d+)/ ); + return match ? match[ 1 ] : phpImageTag; + }; + + it( 'should extract version from full image tag', () => { + expect( extractPhpVersion( 'ghcr.io/automattic/vip-container-images/php-fpm:8.2' ) ).toBe( + '8.2' + ); + } ); + + it( 'should extract version from different image tags', () => { + expect( extractPhpVersion( 'ghcr.io/automattic/vip-container-images/php-fpm:8.1' ) ).toBe( + '8.1' + ); + expect( extractPhpVersion( 'php:7.4' ) ).toBe( '7.4' ); + } ); + + it( 'should return original string if no version found', () => { + expect( extractPhpVersion( 'invalid-tag' ) ).toBe( 'invalid-tag' ); + } ); +} ); diff --git a/src/modules/vip/lib/vip-environment.ts b/src/modules/vip/lib/vip-environment.ts new file mode 100644 index 0000000000..2a80a68f30 --- /dev/null +++ b/src/modules/vip/lib/vip-environment.ts @@ -0,0 +1,494 @@ +/** + * VIP Local Development Environment detection and management. + * + * This module reads VIP environment data directly from the filesystem + * rather than parsing CLI output, providing a more reliable integration. + */ + +import { spawn, exec } from 'child_process'; +import fs from 'fs'; +import fsPromises from 'fs/promises'; +import { homedir, platform } from 'os'; +import path from 'path'; +import { promisify } from 'util'; +import type { + VipCliStatus, + VipCommandResult, + VipCreateOptions, + VipEnvironment, + VipInstanceData, + VipStartOptions, +} from '../types'; + +const execAsync = promisify( exec ); + +/** + * Get the path to VIP's data directory. + * Uses XDG_DATA_HOME on Linux/macOS, or LOCALAPPDATA on Windows. + */ +function getVipDataPath(): string { + const os = platform(); + + if ( os === 'win32' ) { + const localAppData = process.env.LOCALAPPDATA || path.join( homedir(), 'AppData', 'Local' ); + return path.join( localAppData, 'vip' ); + } + + // Linux and macOS use XDG_DATA_HOME or default to ~/.local/share + const xdgDataHome = process.env.XDG_DATA_HOME || path.join( homedir(), '.local', 'share' ); + return path.join( xdgDataHome, 'vip' ); +} + +/** + * Get the path to VIP's dev-environment directory. + */ +export function getVipDevEnvPath(): string { + return path.join( getVipDataPath(), 'dev-environment' ); +} + +/** + * Extract PHP version from the full image tag. + * @example "ghcr.io/automattic/vip-container-images/php-fpm:8.2" -> "8.2" + */ +function extractPhpVersion( phpImageTag: string ): string { + const match = phpImageTag.match( /:(\d+\.\d+)/ ); + return match ? match[ 1 ] : phpImageTag; +} + +/** + * Read VIP instance data from a specific environment. + */ +async function readInstanceData( envPath: string ): Promise< VipInstanceData | null > { + const instanceDataPath = path.join( envPath, 'instance_data.json' ); + + try { + const content = await fsPromises.readFile( instanceDataPath, 'utf-8' ); + return JSON.parse( content ) as VipInstanceData; + } catch ( error ) { + // Environment doesn't exist or has invalid data + return null; + } +} + +/** + * Check if a VIP environment is running by querying Docker. + * VIP/Lando uses project names prefixed with "vipdev". + */ +export async function isVipEnvironmentRunning( slug: string ): Promise< boolean > { + try { + const { stdout } = await execAsync( + `docker ps --filter "label=com.docker.compose.project=vipdev${ slug }" --format "{{.ID}}"`, + { timeout: 5000 } + ); + return stdout.trim().length > 0; + } catch { + // Docker not running or command failed + return false; + } +} + +/** + * Get URLs for a running VIP environment by querying Docker labels. + */ +async function getVipEnvironmentUrls( slug: string ): Promise< string[] > { + try { + // Get the nginx container's labels which contain the Traefik routing info + const { stdout } = await execAsync( + `docker ps --filter "label=com.docker.compose.project=vipdev${ slug }" --filter "label=com.docker.compose.service=nginx" --format "{{.ID}}"`, + { timeout: 5000 } + ); + + const containerId = stdout.trim(); + if ( ! containerId ) { + return []; + } + + // The default URL pattern for VIP dev environments + return [ `http://${ slug }.vipdev.lndo.site/` ]; + } catch { + return []; + } +} + +/** + * List all VIP environments. + */ +export async function listVipEnvironments(): Promise< VipEnvironment[] > { + const devEnvPath = getVipDevEnvPath(); + + // Check if the dev-environment directory exists + if ( ! fs.existsSync( devEnvPath ) ) { + return []; + } + + const entries = await fsPromises.readdir( devEnvPath, { withFileTypes: true } ); + const environments: VipEnvironment[] = []; + + for ( const entry of entries ) { + if ( ! entry.isDirectory() ) { + continue; + } + + const slug = entry.name; + const envPath = path.join( devEnvPath, slug ); + + const instanceData = await readInstanceData( envPath ); + if ( ! instanceData ) { + continue; + } + + // Check if running and get URLs + + const running = await isVipEnvironmentRunning( slug ); + + const urls = running ? await getVipEnvironmentUrls( slug ) : []; + + environments.push( { + slug, + title: instanceData.wpTitle, + running, + phpVersion: extractPhpVersion( instanceData.php ), + wordpressVersion: instanceData.wordpress.tag, + multisite: instanceData.multisite, + elasticsearch: Boolean( instanceData.elasticsearch ), + phpmyadmin: instanceData.phpmyadmin, + xdebug: instanceData.xdebug, + mailpit: instanceData.mailpit, + path: envPath, + urls, + appCodePath: instanceData.appCode.mode === 'local' ? instanceData.appCode.dir : undefined, + muPluginsPath: + instanceData.muPlugins.mode === 'local' ? instanceData.muPlugins.dir : undefined, + autologinKey: instanceData.autologinKey, + adminPassword: instanceData.adminPassword, + } ); + } + + return environments; +} + +/** + * Get details for a specific VIP environment. + */ +export async function getVipEnvironment( slug: string ): Promise< VipEnvironment | null > { + const envPath = path.join( getVipDevEnvPath(), slug ); + const instanceData = await readInstanceData( envPath ); + + if ( ! instanceData ) { + return null; + } + + const running = await isVipEnvironmentRunning( slug ); + const urls = running ? await getVipEnvironmentUrls( slug ) : []; + + return { + slug, + title: instanceData.wpTitle, + running, + phpVersion: extractPhpVersion( instanceData.php ), + wordpressVersion: instanceData.wordpress.tag, + multisite: instanceData.multisite, + elasticsearch: Boolean( instanceData.elasticsearch ), + phpmyadmin: instanceData.phpmyadmin, + xdebug: instanceData.xdebug, + mailpit: instanceData.mailpit, + path: envPath, + urls, + appCodePath: instanceData.appCode.mode === 'local' ? instanceData.appCode.dir : undefined, + muPluginsPath: instanceData.muPlugins.mode === 'local' ? instanceData.muPlugins.dir : undefined, + autologinKey: instanceData.autologinKey, + adminPassword: instanceData.adminPassword, + }; +} + +/** + * Check if VIP CLI is available. + */ +export async function checkVipCliStatus(): Promise< VipCliStatus > { + try { + // Try to get VIP CLI version + const { stdout } = await execAsync( 'vip --version', { timeout: 10000 } ); + const version = stdout.trim(); + + // Try to find the path + const whichCmd = platform() === 'win32' ? 'where vip' : 'which vip'; + let vipPath: string | undefined; + try { + const { stdout: pathOut } = await execAsync( whichCmd, { timeout: 5000 } ); + vipPath = pathOut.trim().split( '\n' )[ 0 ]; + } catch { + // Path lookup failed, but CLI is still available + } + + return { + available: true, + version, + path: vipPath, + }; + } catch ( error ) { + return { + available: false, + error: error instanceof Error ? error.message : 'VIP CLI is not installed or not in PATH', + }; + } +} + +/** + * Get common paths where npm global packages might be installed. + */ +function getCommonNpmPaths(): string[] { + const home = homedir(); + const paths: string[] = []; + + if ( platform() === 'darwin' || platform() === 'linux' ) { + // Common npm global paths on macOS/Linux + paths.push( + '/usr/local/bin', + '/opt/homebrew/bin', // Homebrew on Apple Silicon + '/usr/local/opt/node/bin', + path.join( home, '.npm-global', 'bin' ), + path.join( home, '.nvm', 'versions', 'node' ), // NVM - will need to find actual version + path.join( home, 'n', 'bin' ), // n version manager + path.join( home, '.local', 'bin' ), + '/opt/local/bin' // MacPorts + ); + + // Try to find active Node version from common version managers + const nvmDir = process.env.NVM_DIR || path.join( home, '.nvm' ); + if ( fs.existsSync( nvmDir ) ) { + const versionsDir = path.join( nvmDir, 'versions', 'node' ); + if ( fs.existsSync( versionsDir ) ) { + try { + const versions = fs.readdirSync( versionsDir ); + for ( const version of versions ) { + paths.push( path.join( versionsDir, version, 'bin' ) ); + } + } catch { + // Ignore errors + } + } + } + } + + return paths; +} + +/** + * Execute a VIP CLI command using spawn for better streaming and cross-platform support. + * Uses shell: true to ensure proper PATH resolution. + */ +export async function executeVipCommand( + args: string[], + options: { timeout?: number } = {} +): Promise< VipCommandResult > { + const timeout = options.timeout || 120000; // 2 minute default + + return new Promise( ( resolve ) => { + // Build extended PATH with common npm global locations + const currentPath = process.env.PATH || ''; + const additionalPaths = getCommonNpmPaths(); + const extendedPath = [ ...additionalPaths, currentPath ].join( path.delimiter ); + + // Build the command with slug argument properly formatted + const vipArgs = args.map( ( arg ) => { + // Ensure arguments with = are properly quoted if they contain spaces + if ( arg.includes( '=' ) && arg.includes( ' ' ) ) { + const [ key, ...valueParts ] = arg.split( '=' ); + return `${ key }="${ valueParts.join( '=' ) }"`; + } + return arg; + } ); + + const spawnOptions = { + shell: true, // Important for cross-platform compatibility + env: { + ...process.env, + PATH: extendedPath, + }, + }; + + const childProcess = spawn( 'vip', vipArgs, spawnOptions ); + + let stdout = ''; + let stderr = ''; + let timedOut = false; + + // Set up timeout if specified + let timeoutId: NodeJS.Timeout | undefined; + if ( timeout > 0 ) { + timeoutId = setTimeout( () => { + timedOut = true; + childProcess.kill( 'SIGTERM' ); + }, timeout ); + } + + childProcess.stdout?.on( 'data', ( data: Buffer ) => { + stdout += data.toString(); + } ); + + childProcess.stderr?.on( 'data', ( data: Buffer ) => { + stderr += data.toString(); + } ); + + childProcess.on( 'close', ( code: number | null ) => { + if ( timeoutId ) { + clearTimeout( timeoutId ); + } + + if ( timedOut ) { + resolve( { + success: false, + stdout, + stderr: stderr || 'Command timed out', + exitCode: code, + } ); + return; + } + + // If the command wasn't found, provide a helpful error message + if ( + code === 127 || + stderr.includes( 'command not found' ) || + stderr.includes( 'not recognized' ) + ) { + resolve( { + success: false, + stdout, + stderr: + 'VIP CLI not found. Please ensure @automattic/vip-cli is installed globally (npm install -g @automattic/vip-cli) and restart Studio.', + exitCode: 127, + } ); + return; + } + + resolve( { + success: code === 0, + stdout, + stderr, + exitCode: code, + } ); + } ); + + childProcess.on( 'error', ( error: Error ) => { + if ( timeoutId ) { + clearTimeout( timeoutId ); + } + + resolve( { + success: false, + stdout, + stderr: error.message, + exitCode: null, + } ); + } ); + } ); +} + +/** + * Start a VIP environment. + */ +export async function startVipEnvironment( + slug: string, + options: VipStartOptions = {} +): Promise< VipCommandResult > { + const args = [ 'dev-env', 'start', `--slug=${ slug }` ]; + + if ( options.skipRebuild ) { + args.push( '--skip-rebuild' ); + } + + // Skip WordPress version check prompt to run non-interactively + args.push( '--skip-wp-versions-check' ); + + return executeVipCommand( args, { timeout: 300000 } ); // 5 minute timeout for start +} + +/** + * Stop a VIP environment. + */ +export async function stopVipEnvironment( slug: string ): Promise< VipCommandResult > { + return executeVipCommand( [ 'dev-env', 'stop', `--slug=${ slug }` ], { timeout: 60000 } ); +} + +/** + * Get VIP environment info via CLI (includes health check). + */ +export async function getVipEnvironmentInfo( slug: string ): Promise< VipCommandResult > { + return executeVipCommand( [ 'dev-env', 'info', `--slug=${ slug }` ], { timeout: 30000 } ); +} + +/** + * Open shell in VIP environment. + */ +export async function openVipShell( slug: string ): Promise< VipCommandResult > { + // This will open an interactive shell, so we use a longer timeout + return executeVipCommand( [ 'dev-env', 'shell', `--slug=${ slug }` ], { timeout: 0 } ); +} + +/** + * Create a new VIP environment. + */ +export async function createVipEnvironment( + options: VipCreateOptions +): Promise< VipCommandResult > { + const args = [ 'dev-env', 'create', `--slug=${ options.slug }` ]; + + // Add optional parameters + if ( options.title ) { + args.push( `--title=${ options.title }` ); + } + + if ( options.phpVersion ) { + args.push( `--php=${ options.phpVersion }` ); + } + + if ( options.multisite ) { + if ( options.multisite === true ) { + args.push( '--multisite' ); + } else if ( options.multisite === 'subdomain' || options.multisite === 'subdirectory' ) { + args.push( `--multisite=${ options.multisite }` ); + } + } + + if ( options.appCodePath ) { + args.push( `--app-code=${ options.appCodePath }` ); + } + + if ( options.muPluginsPath ) { + args.push( `--mu-plugins=${ options.muPluginsPath }` ); + } + + if ( options.elasticsearch ) { + args.push( '--elasticsearch' ); + } + + if ( options.phpmyadmin ) { + args.push( '--phpmyadmin' ); + } + + if ( options.xdebug ) { + args.push( '--xdebug' ); + } + + if ( options.mailpit ) { + args.push( '--mailpit' ); + } + + if ( options.photon ) { + args.push( '--photon' ); + } + + if ( options.cron ) { + args.push( '--cron' ); + } + + if ( options.mediaRedirectDomain ) { + args.push( `--media-redirect-domain=${ options.mediaRedirectDomain }` ); + } + + // Add non-interactive flag to avoid prompts + args.push( '--yes' ); + + // Creating an environment can take a while due to Docker image pulls + return executeVipCommand( args, { timeout: 600000 } ); // 10 minute timeout +} diff --git a/src/modules/vip/types.ts b/src/modules/vip/types.ts new file mode 100644 index 0000000000..db99ba8daa --- /dev/null +++ b/src/modules/vip/types.ts @@ -0,0 +1,159 @@ +/** + * Types for VIP Local Development Environment integration. + * + * VIP environments are managed by the VIP CLI and use Lando/Docker under the hood. + * Studio provides a UI layer to detect, display, and manage these environments. + */ + +/** + * Configuration for WordPress component (core, mu-plugins, app-code). + * Mirrors VIP CLI's ComponentConfig. + */ +export interface VipComponentConfig { + mode: 'local' | 'image'; + dir?: string; + image?: string; + tag?: string; +} + +/** + * WordPress-specific configuration. + */ +export interface VipWordPressConfig { + mode: 'image'; + tag: string; + ref?: string; + doNotUpgrade?: boolean; +} + +/** + * VIP environment instance data as stored in instance_data.json. + * This is the schema used by VIP CLI. + */ +export interface VipInstanceData { + siteSlug: string; + wpTitle: string; + multisite: boolean | 'subdomain' | 'subdirectory'; + wordpress: VipWordPressConfig; + muPlugins: VipComponentConfig; + appCode: VipComponentConfig; + mediaRedirectDomain: string; + php: string; + elasticsearch?: boolean | string; + phpmyadmin: boolean; + xdebug: boolean; + xdebugConfig?: string; + mailpit: boolean; + photon: boolean; + cron: boolean; + pullAfter?: number; + autologinKey?: string; + adminPassword?: string; + version?: string; + overrides?: string; +} + +/** + * VIP environment as represented in Studio. + * This is a simplified view of VipInstanceData for UI purposes. + */ +export interface VipEnvironment { + /** Environment slug (unique identifier) */ + slug: string; + /** WordPress site title */ + title: string; + /** Whether the environment is currently running */ + running: boolean; + /** PHP version (e.g., "8.2") */ + phpVersion: string; + /** WordPress version/tag (e.g., "6.7" or "trunk") */ + wordpressVersion: string; + /** Whether this is a multisite installation */ + multisite: boolean | 'subdomain' | 'subdirectory'; + /** Whether Elasticsearch is enabled */ + elasticsearch: boolean; + /** Whether phpMyAdmin is enabled */ + phpmyadmin: boolean; + /** Whether XDebug is enabled */ + xdebug: boolean; + /** Whether Mailpit is enabled */ + mailpit: boolean; + /** Path to the environment directory */ + path: string; + /** Site URLs (when running) */ + urls: string[]; + /** App code directory (if using local mode) */ + appCodePath?: string; + /** MU plugins directory (if using local mode) */ + muPluginsPath?: string; + /** Auto-login key for quick access */ + autologinKey?: string; + /** Admin password */ + adminPassword?: string; +} + +/** + * Result of checking VIP CLI availability. + */ +export interface VipCliStatus { + /** Whether VIP CLI is installed and accessible */ + available: boolean; + /** VIP CLI version string (if available) */ + version?: string; + /** Path to the VIP CLI binary */ + path?: string; + /** Error message if not available */ + error?: string; +} + +/** + * Options for starting a VIP environment. + */ +export interface VipStartOptions { + /** Skip rebuilding containers */ + skipRebuild?: boolean; + /** Skip WordPress version check */ + skipWpVersionCheck?: boolean; +} + +/** + * Options for creating a new VIP environment. + */ +export interface VipCreateOptions { + /** Unique environment slug */ + slug: string; + /** WordPress site title */ + title?: string; + /** PHP version (e.g., "8.2") */ + phpVersion?: string; + /** Multisite configuration */ + multisite?: boolean | 'subdomain' | 'subdirectory'; + /** Path to local application code */ + appCodePath?: string; + /** Path to local MU plugins */ + muPluginsPath?: string; + /** Enable Elasticsearch */ + elasticsearch?: boolean; + /** Enable phpMyAdmin */ + phpmyadmin?: boolean; + /** Enable XDebug */ + xdebug?: boolean; + /** Enable Mailpit */ + mailpit?: boolean; + /** Enable Photon */ + photon?: boolean; + /** Enable Cron */ + cron?: boolean; + /** Media redirect domain */ + mediaRedirectDomain?: string; +} + +/** + * Result from VIP CLI command execution. + */ +export interface VipCommandResult { + success: boolean; + stdout: string; + stderr: string; + exitCode: number | null; +} diff --git a/src/preload.ts b/src/preload.ts index ab6f4e37b3..c1274b55c2 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -151,6 +151,19 @@ const api: IpcApi = { isStudioCliInstalled: () => ipcRendererInvoke( 'isStudioCliInstalled' ), installStudioCli: () => ipcRendererInvoke( 'installStudioCli' ), uninstallStudioCli: () => ipcRendererInvoke( 'uninstallStudioCli' ), + // VIP Local Development Environment + isVipCliAvailable: () => ipcRendererInvoke( 'isVipCliAvailable' ), + getVipEnvironments: () => ipcRendererInvoke( 'getVipEnvironments' ), + getVipEnvironmentDetails: ( slug ) => ipcRendererInvoke( 'getVipEnvironmentDetails', slug ), + startVipEnv: ( slug, options ) => ipcRendererInvoke( 'startVipEnv', slug, options ), + stopVipEnv: ( slug ) => ipcRendererInvoke( 'stopVipEnv', slug ), + createVipEnv: ( options ) => ipcRendererInvoke( 'createVipEnv', options ), + executeVipCliCommand: ( args ) => ipcRendererInvoke( 'executeVipCliCommand', args ), + openVipEnvironmentFolder: ( slug ) => ipcRendererInvoke( 'openVipEnvironmentFolder', slug ), + openVipAppCodeFolder: ( slug ) => ipcRendererInvoke( 'openVipAppCodeFolder', slug ), + openVipEnvironmentInBrowser: ( slug, useAutologin ) => + ipcRendererInvoke( 'openVipEnvironmentInBrowser', slug, useAutologin ), + getVipDataPath: () => ipcRendererInvoke( 'getVipDataPath' ), }; contextBridge.exposeInMainWorld( 'ipcApi', api ); diff --git a/vite.cli.config.ts b/vite.cli.config.ts index 412c9e8f6c..fe0f4a269f 100644 --- a/vite.cli.config.ts +++ b/vite.cli.config.ts @@ -52,6 +52,7 @@ export default defineConfig( { 'fs/promises', 'dns/promises', 'pm2', + 'pm2-axon', // `trash` includes a native macOS binary that Vite/Rollup inlines as a base64 string, which // generates an error in the production build 'trash',