Skip to content
Open
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: 12 additions & 2 deletions bin/studio-cli.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions bin/studio-cli.sh
Original file line number Diff line number Diff line change
@@ -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
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"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: Missing validation of symlink target

Before removing the symlink, consider verifying that it actually points to a Studio CLI location. This would prevent accidental deletion of symlinks that happen to have the same name but point elsewhere.

Additionally, consider checking if the path is actually a symlink before attempting to remove it to provide better error messages.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: Missing Symlink Validation

The script doesn't verify that the symlink actually points to Studio CLI before removing it. Consider adding validation to prevent accidental deletion of similarly-named symlinks:

if [ -L "$CLI_SYMLINK_PATH" ]; then
  TARGET=$(readlink "$CLI_SYMLINK_PATH")
  # Optionally verify TARGET matches expected Studio CLI path
  rm "$CLI_SYMLINK_PATH"
else
  echo >&2 "Warning: $CLI_SYMLINK_PATH is not a symlink"
  exit 1
fi

This would prevent accidental deletion if something else creates a non-symlink file at this path.

18 changes: 16 additions & 2 deletions docs/ai-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,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)
Expand All @@ -169,8 +174,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
Expand Down Expand Up @@ -353,6 +367,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
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();

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removal of Auto-Install: Document why this was removed

The removal of automatic CLI installation on Windows startup is a significant behavior change. While it's mentioned in the PR description, consider adding a comment in the code explaining why this was removed:

// Previously, CLI was auto-installed on Windows startup. This has been moved
// to a user-controlled toggle in Settings to give users more control.
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
70 changes: 70 additions & 0 deletions src/modules/cli/lib/installation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { dialog } from 'electron';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at installation folder, it seems that we could go with factory class and automatically create instalce of it depending on OS, as a result we coudl avoid such things as if ( process.platform !== 'win32' ) { here, unify function names, less conditions, etc. I see that we export only install/uninstall/is functions from there, so we won't have Liskov substitution principle issues and it will be more structured and easy in the future to add other OS if we need.

No action needed, just wondering your opinion as you know the codebase better and I would like to understand :)

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 > {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Quality: Linux support should be documented

The function returns false for Linux, but there's no comment explaining why CLI installation is not supported on Linux. Consider adding a comment:

export async function isStudioCliInstalled(): Promise< boolean > {
	switch ( process.platform ) {
		case 'darwin':
			return await isCliInstalledMacOS();
		case 'win32':
			return await isCliInstalledWindows();
		default:
			// CLI installation is not yet supported on Linux
			return false;
	}
}

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

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