diff --git a/bin/uninstall-studio-cli.sh b/bin/uninstall-studio-cli.sh new file mode 100755 index 0000000000..49ec54a012 --- /dev/null +++ b/bin/uninstall-studio-cli.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +# This script is used to uninstall the Studio CLI on macOS. It removes the symlink at +# CLI_SYMLINK_PATH (e.g. /usr/local/bin/studio) + +# Exit if any command fails +set -e + +if [ -z "$CLI_SYMLINK_PATH" ]; then + echo >&2 "Error: CLI_SYMLINK_PATH environment variable must be set" + exit 1 +fi + +rm "$CLI_SYMLINK_PATH" diff --git a/src/index.ts b/src/index.ts index c5a64fb488..05787b5e16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,7 +43,6 @@ import { import { migrateAllDatabasesInSitu } from 'src/migrations/move-databases-in-situ'; import { removeSitesWithEmptyDirectories } from 'src/migrations/remove-sites-with-empty-dirs'; import { renameLaunchUniquesStat } from 'src/migrations/rename-launch-uniques-stat'; -import { installCLIOnWindows } from 'src/modules/cli/lib/install-windows'; import { setupWPServerFiles, updateWPServerFiles } from 'src/setup-wp-server-files'; import { stopAllServersOnQuit } from 'src/site-server'; import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; @@ -335,7 +334,6 @@ async function appBoot() { 'monthly' ); - await installCLIOnWindows(); getWordPressProvider(); finishedInitialization = true; diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index 2222476ead..9bb8659435 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -102,6 +102,11 @@ import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; import type { WpCliResult } from 'src/lib/wp-cli-process'; import type { RawDirectoryEntry } from 'src/modules/sync/types'; import type { SyncOption } from 'src/types'; +export { + isStudioCliInstalled, + installStudioCli, + uninstallStudioCli, +} from 'src/modules/cli/lib/installation'; /** * Registry to store AbortControllers for ongoing sync operations (push/pull). diff --git a/src/menu.ts b/src/menu.ts index 09c8ad120c..52c3288db7 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -27,7 +27,6 @@ import { getUserLocaleWithFallback } from 'src/lib/locale-node'; import { shellOpenExternalWrapper } from 'src/lib/shell-open-external-wrapper'; import { promptWindowsSpeedUpSites } from 'src/lib/windows-helpers'; import { getMainWindow } from 'src/main-window'; -import { installCLIOnMacOSWithConfirmation } from 'src/modules/cli/lib/install-macos'; import { isUpdateReadyToInstall, manualCheckForUpdates } from 'src/updates'; export async function setupMenu( config: { needsOnboarding: boolean } ) { @@ -146,14 +145,6 @@ async function getAppMenu( void sendIpcEventToRenderer( 'user-settings', { tabName: 'preferences' } ); }, }, - ...( process.platform === 'darwin' - ? [ - { - label: __( 'Install CLI…' ), - click: installCLIOnMacOSWithConfirmation, - }, - ] - : [] ), { label: __( 'Beta Features' ), submenu: betaFeaturesMenu, diff --git a/src/modules/cli/lib/install-macos.ts b/src/modules/cli/lib/installation/darwin.ts similarity index 64% rename from src/modules/cli/lib/install-macos.ts rename to src/modules/cli/lib/installation/darwin.ts index 5f9c0b1e7f..5e5c8f372b 100644 --- a/src/modules/cli/lib/install-macos.ts +++ b/src/modules/cli/lib/installation/darwin.ts @@ -7,22 +7,23 @@ import { isErrnoException } from 'common/lib/is-errno-exception'; import { sudoExec } from 'src/lib/sudo-exec'; import { getMainWindow } from 'src/main-window'; import { getResourcesPath } from 'src/storage/paths'; -import packageJson from '../../../../package.json'; +import packageJson from '../../../../../package.json'; const cliSymlinkPath = '/usr/local/bin/studio'; const binPath = path.join( getResourcesPath(), 'bin' ); const cliPackagedPath = path.join( binPath, 'studio-cli.sh' ); const installScriptPath = path.join( binPath, 'install-studio-cli.sh' ); +const uninstallScriptPath = path.join( binPath, 'uninstall-studio-cli.sh' ); const ERROR_WRONG_PLATFORM = 'Studio CLI is only available on macOS'; const ERROR_FILE_ALREADY_EXISTS = 'Studio CLI symlink path already occupied by non-symlink'; // Defined in @vscode/sudo-prompt const ERROR_PERMISSION = 'User did not grant permission.'; -export async function installCLIOnMacOSWithConfirmation() { +export async function installCliWithConfirmation() { try { - await installCLI(); + await installCli(); const mainWindow = await getMainWindow(); await dialog.showMessageBox( mainWindow, { type: 'info', @@ -60,9 +61,34 @@ export async function installCLIOnMacOSWithConfirmation() { } } -// This function installs the Studio CLI on macOS. It creates a symlink at `cliSymlinkPath` pointing -// to the packaged Studio CLI JS file at `cliPackagedPath`. -async function installCLI(): Promise< void > { +export async function uninstallCliWithConfirmation() { + try { + await uninstallCli(); + const mainWindow = await getMainWindow(); + await dialog.showMessageBox( mainWindow, { + type: 'info', + title: __( 'CLI Installed' ), + message: __( 'The CLI has been installed successfully.' ), + } ); + } catch ( error ) { + let message: string = __( + 'There was an unknown error. Please check the logs for more information.' + ); + + if ( error instanceof Error ) { + message = error.message; + } + + const mainWindow = await getMainWindow(); + await dialog.showMessageBox( mainWindow, { + type: 'error', + title: __( 'Failed to uninstall CLI' ), + message, + } ); + } +} + +export async function uninstallCli() { if ( process.platform !== 'darwin' ) { throw new Error( ERROR_WRONG_PLATFORM ); } @@ -81,10 +107,47 @@ async function installCLI(): Promise< void > { } } + try { + await unlink( cliSymlinkPath ); + } catch ( error ) { + // `/usr/local/bin` is not typically writable by non-root users, so in most cases, we run + // this uninstall script with admin privileges to remove the symlink. + await sudoExec( `/bin/sh "${ uninstallScriptPath }"`, { + name: packageJson.productName, + env: { + CLI_SYMLINK_PATH: cliSymlinkPath, + }, + } ); + } +} + +export async function isCliInstalled() { const currentSymlinkDestination = await getCurrentSymlinkDestination(); + return currentSymlinkDestination === cliPackagedPath; +} + +// This function installs the Studio CLI on macOS. It creates a symlink at `cliSymlinkPath` pointing +// to the packaged Studio CLI JS file at `cliPackagedPath`. +async function installCli(): Promise< void > { + if ( process.platform !== 'darwin' ) { + throw new Error( ERROR_WRONG_PLATFORM ); + } + + try { + const stats = await lstat( cliSymlinkPath ); + + if ( ! stats.isSymbolicLink() ) { + throw new Error( ERROR_FILE_ALREADY_EXISTS ); + } + } catch ( error ) { + if ( isErrnoException( error ) && error.code === 'ENOENT' ) { + // File does not exist, which means we can proceed with the installation. + } else { + throw error; + } + } - // The CLI is already installed. - if ( currentSymlinkDestination === cliPackagedPath ) { + if ( await isCliInstalled() ) { return; } diff --git a/src/modules/cli/lib/installation/index.ts b/src/modules/cli/lib/installation/index.ts new file mode 100644 index 0000000000..416c65872a --- /dev/null +++ b/src/modules/cli/lib/installation/index.ts @@ -0,0 +1,37 @@ +import { + installCliWithConfirmation as installCliMacOS, + isCliInstalled as isCliInstalledMacOS, + uninstallCliWithConfirmation as uninstallCliOnMacOS, +} from 'src/modules/cli/lib/installation/darwin'; +import { + installCli as installCliOnWindows, + isCliInstalled as isCliInstalledWindows, + uninstallCli as uninstallCliOnWindows, +} from 'src/modules/cli/lib/installation/win32'; + +export async function isStudioCliInstalled(): Promise< boolean > { + switch ( process.platform ) { + case 'darwin': + return await isCliInstalledMacOS(); + case 'win32': + return await isCliInstalledWindows(); + default: + return false; + } +} + +export async function installStudioCli(): Promise< void > { + if ( process.platform === 'darwin' ) { + await installCliMacOS(); + } else if ( process.platform === 'win32' ) { + await installCliOnWindows(); + } +} + +export async function uninstallStudioCli(): Promise< void > { + if ( process.platform === 'darwin' ) { + await uninstallCliOnMacOS(); + } else if ( process.platform === 'win32' ) { + await uninstallCliOnWindows(); + } +} diff --git a/src/modules/cli/lib/install-windows.ts b/src/modules/cli/lib/installation/win32.ts similarity index 73% rename from src/modules/cli/lib/install-windows.ts rename to src/modules/cli/lib/installation/win32.ts index edd9f0bcee..069dec2035 100644 --- a/src/modules/cli/lib/install-windows.ts +++ b/src/modules/cli/lib/installation/win32.ts @@ -1,5 +1,6 @@ import { app } from 'electron'; import { mkdir, writeFile } from 'fs/promises'; +import { existsSync } from 'node:fs'; import path from 'path'; import * as Sentry from '@sentry/electron/main'; import { __ } from '@wordpress/i18n'; @@ -14,7 +15,7 @@ const currentUserRegistry = new Registry( { key: '\\Environment', } ); -const getPathFromRegistry = (): Promise< string > => { +function getPathFromRegistry(): Promise< string > { return new Promise( ( resolve, reject ) => { currentUserRegistry.get( PATH_KEY, ( error, item ) => { if ( error ) { @@ -24,9 +25,9 @@ const getPathFromRegistry = (): Promise< string > => { resolve( item?.value || '' ); } ); } ); -}; +} -const setPathInRegistry = ( updatedPath: string ): Promise< void > => { +function setPathInRegistry( updatedPath: string ): Promise< void > { return new Promise( ( resolve, reject ) => { currentUserRegistry.set( PATH_KEY, Registry.REG_EXPAND_SZ, updatedPath, ( error ) => { if ( error ) { @@ -36,16 +37,16 @@ const setPathInRegistry = ( updatedPath: string ): Promise< void > => { resolve(); } ); } ); -}; +} -const isStudioCliInPath = ( pathValue: string ): boolean => { +function isStudioCliInPath( pathValue: string ): boolean { return pathValue .split( ';' ) .map( ( item ) => item.trim().toLowerCase() ) .includes( unversionedBinDirPath.toLowerCase() ); -}; +} -const installPath = async () => { +async function installPath() { try { const currentPath = await getPathFromRegistry(); @@ -65,7 +66,7 @@ const installPath = async () => { Sentry.captureException( error ); console.error( 'Failed to install CLI: PATH to Registry', error ); } -}; +} /** * Creates a proxy batch file in a stable location to handle CLI execution. @@ -74,7 +75,7 @@ const installPath = async () => { * Instead of adding the versioned executable directly to PATH, we create a fixed proxy script * in the AppData directory that forwards execution to the current version's CLI entry point. */ -const installProxyBatFile = async () => { +async function installProxyBatFile() { try { await mkdir( unversionedBinDirPath, { recursive: true } ); @@ -91,13 +92,42 @@ const installProxyBatFile = async () => { Sentry.captureException( error ); console.error( 'Failed to install CLI: Proxy Bat file', error ); } -}; +} -export const installCLIOnWindows = async () => { +export async function isCliInstalled() { + try { + const currentPath = await getPathFromRegistry(); + + if ( ! isStudioCliInPath( currentPath ) ) { + return false; + } + + if ( ! existsSync( unversionedBinDirPath ) ) { + return false; + } + + return true; + } catch ( error ) { + console.error( 'Failed to check installation status of CLI', error ); + return false; + } +} + +export async function uninstallCli() { + const currentPath = await getPathFromRegistry(); + const newPath = currentPath + .split( ';' ) + .filter( ( item ) => item.trim().toLowerCase() !== unversionedBinDirPath.toLowerCase() ) + .join( ';' ); + + await setPathInRegistry( newPath ); +} + +export async function installCli() { if ( process.platform !== 'win32' || process.env.NODE_ENV === 'development' ) { return; } await installPath(); await installProxyBatFile(); -}; +} diff --git a/src/modules/user-settings/components/preferences-tab.tsx b/src/modules/user-settings/components/preferences-tab.tsx index cf7908e7be..0405152d11 100644 --- a/src/modules/user-settings/components/preferences-tab.tsx +++ b/src/modules/user-settings/components/preferences-tab.tsx @@ -1,10 +1,13 @@ import { useI18n } from '@wordpress/react-i18n'; import { useState } from 'react'; +import { SupportedLocale } from 'common/lib/locale'; import Button from 'src/components/button'; import { EditorPicker } from 'src/modules/user-settings/components/editor-picker'; import { LanguagePicker } from 'src/modules/user-settings/components/language-picker'; +import { StudioCliToggle } from 'src/modules/user-settings/components/studio-cli-toggle'; import { TerminalPicker } from 'src/modules/user-settings/components/terminal-picker'; import { SupportedEditor } from 'src/modules/user-settings/lib/editor'; +import { SupportedTerminal } from 'src/modules/user-settings/lib/terminal'; import { useAppDispatch, useI18nLocale } from 'src/stores'; import { saveUserLocale } from 'src/stores/i18n-slice'; import { @@ -12,52 +15,67 @@ import { useGetUserTerminalQuery, useSaveUserEditorMutation, useSaveUserTerminalMutation, + useGetStudioCliIsInstalledQuery, + useSaveStudioCliIsInstalledMutation, } from 'src/stores/installed-apps-api'; export const PreferencesTab = ( { onClose }: { onClose: () => void } ) => { const { __ } = useI18n(); const savedLocale = useI18nLocale(); const dispatch = useAppDispatch(); - const [ locale, setLocale ] = useState( savedLocale ); const { data: editor } = useGetUserEditorQuery(); - const { data: terminal = 'terminal' } = useGetUserTerminalQuery(); + const { data: terminal } = useGetUserTerminalQuery(); + const { data: isCliInstalled } = useGetStudioCliIsInstalledQuery(); + const [ saveEditor ] = useSaveUserEditorMutation(); const [ saveTerminal ] = useSaveUserTerminalMutation(); + const [ saveCliIsInstalled ] = useSaveStudioCliIsInstalledMutation(); - const [ currentEditor, setCurrentEditor ] = useState< SupportedEditor | null >( editor ?? null ); - const [ currentTerminal, setCurrentTerminal ] = useState( terminal ); + const [ dirtyLocale, setDirtyLocale ] = useState< SupportedLocale >(); + const [ dirtyEditor, setDirtyEditor ] = useState< SupportedEditor | null >(); + const [ dirtyTerminal, setDirtyTerminal ] = useState< SupportedTerminal >(); + const [ dirtyIsCliInstalled, setDirtyIsCliInstalled ] = useState< boolean >(); const savePreferences = async () => { - await dispatch( saveUserLocale( locale ) ); - if ( currentEditor ) { - await saveEditor( currentEditor ); + if ( dirtyLocale ) { + await dispatch( saveUserLocale( dirtyLocale ) ); + } + if ( dirtyEditor ) { + await saveEditor( dirtyEditor ); + } + if ( dirtyTerminal ) { + await saveTerminal( dirtyTerminal ); + } + if ( dirtyIsCliInstalled !== undefined ) { + await saveCliIsInstalled( dirtyIsCliInstalled ); } - await saveTerminal( currentTerminal ); onClose(); }; - const cancelChanges = () => { - setLocale( savedLocale ); - setCurrentEditor( editor ?? null ); - setCurrentTerminal( terminal ); - onClose(); - }; + const localeSelection = dirtyLocale ?? savedLocale ?? 'en'; + const editorSelection = dirtyEditor ?? editor ?? 'vscode'; + const terminalSelection = dirtyTerminal ?? terminal ?? 'terminal'; + const isCliInstalledSelection = dirtyIsCliInstalled ?? isCliInstalled ?? false; const hasChanges = - locale !== savedLocale || currentEditor !== editor || currentTerminal !== terminal; + ( dirtyLocale && dirtyLocale !== savedLocale ) || + ( dirtyEditor && dirtyEditor !== editor ) || + ( dirtyTerminal && dirtyTerminal !== terminal ) || + ( dirtyIsCliInstalled !== undefined && dirtyIsCliInstalled !== isCliInstalled ); return ( <> - + - -
-