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 (
<>
-