Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions bin/uninstall-studio-cli.sh
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 0 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -335,7 +334,6 @@ async function appBoot() {
'monthly'
);

await installCLIOnWindows();
getWordPressProvider();

finishedInitialization = true;
Expand Down
5 changes: 5 additions & 0 deletions src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
9 changes: 0 additions & 9 deletions src/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } ) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 );
}
Expand All @@ -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;
}

Expand Down
37 changes: 37 additions & 0 deletions src/modules/cli/lib/installation/index.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 ) {
Expand All @@ -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 ) {
Expand All @@ -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();

Expand All @@ -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.
Expand All @@ -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 } );

Expand All @@ -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();
};
}
Loading