diff --git a/bin/studio-cli.bat b/bin/studio-cli.bat index 2823615f6e..b34b9c1a87 100644 --- a/bin/studio-cli.bat +++ b/bin/studio-cli.bat @@ -8,8 +8,18 @@ set ORIGINAL_CP=%ORIGINAL_CP: =% rem Set code page to UTF-8 chcp 65001 >nul -set ELECTRON_RUN_AS_NODE=1 -call "%~dp0..\..\Studio.exe" "%~dp0..\cli\main.js" %* +set ELECTRON_EXECUTABLE=%~dp0..\..\Studio.exe +set CLI_SCRIPT=%~dp0..\cli\main.js + +if exist "%ELECTRON_EXECUTABLE%" ( + set ELECTRON_RUN_AS_NODE=1 + call "%ELECTRON_EXECUTABLE%" "%CLI_SCRIPT%" %* +) else ( + if not exist "%CLI_SCRIPT%" ( + set CLI_SCRIPT=%~dp0..\dist\cli\main.js + ) + call node "%CLI_SCRIPT%" %* +) rem Restore original code page chcp %ORIGINAL_CP% >nul diff --git a/bin/studio-cli.sh b/bin/studio-cli.sh index 89f3d22a7e..0aa6bd1c70 100755 --- a/bin/studio-cli.sh +++ b/bin/studio-cli.sh @@ -1,13 +1,19 @@ #!/bin/sh -# This script is assumed to live in `/Applications/Studio.app/Contents/Resources/bin/studio-cli.sh` +# The default assumption is that this script lives in `/Applications/Studio.app/Contents/Resources/bin/studio-cli.sh` CONTENTS_DIR=$(dirname "$(dirname "$(dirname "$(realpath "$0")")")") ELECTRON_EXECUTABLE="$CONTENTS_DIR/MacOS/Studio" CLI_SCRIPT="$CONTENTS_DIR/Resources/cli/main.js" -if ! [ -x "$ELECTRON_EXECUTABLE" ]; then - echo >&2 "'Studio' executable not found" - exit 1 -fi +if [ -x "$ELECTRON_EXECUTABLE" ]; then + ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON_EXECUTABLE" "$CLI_SCRIPT" "$@" +else + # If the default script path is not found, assume that this script lives in the development directory + # and look for the CLI JS bundle in the `./dist` directory + if ! [ -f "$CLI_SCRIPT" ]; then + SCRIPT_DIR=$(dirname $(dirname "$(realpath "$0")")) + CLI_SCRIPT="$SCRIPT_DIR/dist/cli/main.js" + fi -ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON_EXECUTABLE" "$CLI_SCRIPT" "$@" + exec node "$CLI_SCRIPT" "$@" +fi 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/docs/ai-instructions.md b/docs/ai-instructions.md index 75665766ed..353dceb337 100644 --- a/docs/ai-instructions.md +++ b/docs/ai-instructions.md @@ -258,6 +258,11 @@ window.ipcApi.openSiteURL(id) // Send (one-way) // Main (src/ipc-handlers.ts) handles: ipcMain.handle('startServer', async (event, siteId) => { ... }) ipcMain.on('openSiteURL', (event, id) => { ... }) + +// CLI Installation API (delegated to src/modules/cli/lib/installation): +window.ipcApi.isStudioCliInstalled() // Check CLI installation status +window.ipcApi.installStudioCli() // Install the CLI +window.ipcApi.uninstallStudioCli() // Uninstall the CLI ``` ### 3. WordPress Provider Pattern (Strategy Pattern) @@ -281,8 +286,17 @@ Both implement the `WordPressProvider` interface with methods: - provider constants: WordPress/PHP versions - RTK Query APIs for data fetching: - wpcomApi: WordPress.com API calls - - installedAppsApi: System apps detection + - installedAppsApi: System apps detection, CLI installation status - wordpressVersionsApi: Available WP versions + +// installedAppsApi endpoints (src/stores/installed-apps-api.ts): +- getStudioCliIsInstalled: Query CLI installation status +- getInstalledApps: Query installed editors and terminals +- getUserEditor: Get user's preferred editor +- getUserTerminal: Get user's preferred terminal +- saveStudioCliIsInstalled: Mutation to install/uninstall CLI +- saveUserEditor: Mutation to save preferred editor +- saveUserTerminal: Mutation to save preferred terminal ``` ### 5. Site Management @@ -465,6 +479,6 @@ Local component state used for temporary UI interactions. --- -Last Updated: 2025-11-05 +Last Updated: 2025-11-10 Repository: https://github.com/Automattic/studio License: GPLv2 or later 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 33c6dfdfde..b64ffeeeeb 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 694684dd5f..af22815984 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 } ) { @@ -147,14 +146,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/installation/index.ts b/src/modules/cli/lib/installation/index.ts new file mode 100644 index 0000000000..0c05832519 --- /dev/null +++ b/src/modules/cli/lib/installation/index.ts @@ -0,0 +1,70 @@ +import { dialog } from 'electron'; +import { __ } from '@wordpress/i18n'; +import { getMainWindow } from 'src/main-window'; +import { + installCliWithConfirmation as installCliMacOS, + isCliInstalled as isCliInstalledMacOS, + uninstallCliWithConfirmation as uninstallCliOnMacOS, +} from 'src/modules/cli/lib/installation/macos'; +import { + installCli as installCliOnWindows, + isCliInstalled as isCliInstalledWindows, + uninstallCli as uninstallCliOnWindows, +} from 'src/modules/cli/lib/installation/windows'; + +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.env.NODE_ENV !== 'production' ) { + const mainWindow = await getMainWindow(); + const { response } = await dialog.showMessageBox( mainWindow, { + type: 'warning', + buttons: [ __( 'Proceed' ), __( 'Cancel' ) ], + title: 'You are running a development version of Studio', + message: + 'If you proceed with the CLI installation, the CLI will use the system-level `node` runtime to execute commands instead of the Electron node runtime (which is what is used in production).', + } ); + + if ( response === 1 ) { + return; + } + } + + if ( process.platform === 'darwin' ) { + await installCliMacOS(); + } else if ( process.platform === 'win32' ) { + await installCliOnWindows(); + } +} + +export async function uninstallStudioCli(): Promise< void > { + if ( process.env.NODE_ENV !== 'production' ) { + const mainWindow = await getMainWindow(); + const { response } = await dialog.showMessageBox( mainWindow, { + type: 'warning', + buttons: [ __( 'Proceed' ), __( 'Cancel' ) ], + title: 'You are running a development version of Studio', + message: + 'By uninstalling the CLI, you may be removing a version that uses the Electron runtime to execute commands. If you install the CLI again using this version of the app, a different node runtime will be used to execute commands.', + } ); + + if ( response === 1 ) { + return; + } + } + + if ( process.platform === 'darwin' ) { + await uninstallCliOnMacOS(); + } else if ( process.platform === 'win32' ) { + await uninstallCliOnWindows(); + } +} diff --git a/src/modules/cli/lib/install-macos.ts b/src/modules/cli/lib/installation/macos.ts similarity index 63% rename from src/modules/cli/lib/install-macos.ts rename to src/modules/cli/lib/installation/macos.ts index 5f9c0b1e7f..5dffa2be6b 100644 --- a/src/modules/cli/lib/install-macos.ts +++ b/src/modules/cli/lib/installation/macos.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,14 @@ export async function installCLIOnMacOSWithConfirmation() { } } +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 > { +async function installCli(): Promise< void > { if ( process.platform !== 'darwin' ) { throw new Error( ERROR_WRONG_PLATFORM ); } @@ -81,10 +87,7 @@ async function installCLI(): Promise< void > { } } - const currentSymlinkDestination = await getCurrentSymlinkDestination(); - - // The CLI is already installed. - if ( currentSymlinkDestination === cliPackagedPath ) { + if ( await isCliInstalled() ) { return; } @@ -107,6 +110,69 @@ async function installCLI(): Promise< void > { } } +export async function uninstallCliWithConfirmation() { + try { + await uninstallCli(); + const mainWindow = await getMainWindow(); + await dialog.showMessageBox( mainWindow, { + type: 'info', + title: __( 'CLI Uninstalled' ), + message: __( 'The CLI has been uninstalled successfully.' ), + } ); + } catch ( error ) { + Sentry.captureException( error ); + console.error( 'Failed to uninstall CLI', 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, + } ); + } +} + +async function uninstallCli() { + 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 + } else { + throw error; + } + } + + 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, + }, + } ); + } +} + async function getCurrentSymlinkDestination(): Promise< string | null > { try { return await readlink( cliSymlinkPath ); diff --git a/src/modules/cli/lib/install-windows.ts b/src/modules/cli/lib/installation/windows.ts similarity index 67% rename from src/modules/cli/lib/install-windows.ts rename to src/modules/cli/lib/installation/windows.ts index edd9f0bcee..59a34668f9 100644 --- a/src/modules/cli/lib/install-windows.ts +++ b/src/modules/cli/lib/installation/windows.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(); @@ -63,9 +64,9 @@ const installPath = async () => { await setPathInRegistry( updatedPath ); } catch ( error ) { Sentry.captureException( error ); - console.error( 'Failed to install CLI: PATH to Registry', error ); + console.error( 'Failed to install CLI', 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,47 @@ const installProxyBatFile = async () => { Sentry.captureException( error ); console.error( 'Failed to install CLI: Proxy Bat file', error ); } -}; +} -export const installCLIOnWindows = async () => { - if ( process.platform !== 'win32' || process.env.NODE_ENV === 'development' ) { +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() { + try { + const currentPath = await getPathFromRegistry(); + const newPath = currentPath + .split( ';' ) + .filter( ( item ) => item.trim().toLowerCase() !== unversionedBinDirPath.toLowerCase() ) + .join( ';' ); + + await setPathInRegistry( newPath ); + } catch ( error ) { + Sentry.captureException( error ); + console.error( 'Failed to uninstall CLI', error ); + } +} + +export async function installCli() { + if ( process.platform !== 'win32' ) { 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 98d5cce3dc..85b568d83d 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,54 +15,70 @@ 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; + const hasChanges = [ + [ dirtyLocale, savedLocale ], + [ dirtyEditor, editor ], + [ dirtyTerminal, terminal ], + [ dirtyIsCliInstalled, isCliInstalled ], + ].some( ( [ a, b ] ) => a !== undefined && a !== b ); return ( <> - + - + +