Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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"
Copy link
Contributor

Choose a reason for hiding this comment

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

It's a bit out of scope here, but as you are making more improvements around installing/uninstalling, I would like ask - what do you think about unsetting NODE_OPTIONS in the wrapper to fix the warning that can appear in console when it's set for the terminal session?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is fair 👍 I've made the change

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For future reference, Wojtek was referring to the warning described in #2128


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 @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
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 {
} from 'src/migrations/migrate-from-wp-now-folder';
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 @@ -333,7 +332,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 @@ -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,
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';
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 @@ -65,9 +66,14 @@ export async function installCLIOnMacOSWithConfirmation() {
}
}

export async function isCliInstalled() {
const currentSymlinkDestination = await getCurrentSymlinkDestination();
Copy link
Contributor

Choose a reason for hiding this comment

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

This check causes marking CLI as not installed when I install it from the built app and run the app in dev mode.

Should we adjust it to treat it as installed in both cases? Or at least, always treat the one from production build as installed, and dev link as uninstalled, so user running production build could install it again, overwriting any existing dev mode link?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There's some nuance to this issue. For context, installing the CLI from the Studio production build yields different results than installing it from the development build. This is why I added the warning modals in src/modules/cli/lib/installation/index.ts when running the app in development mode.

So, why is there a difference to begin with? This is because the symlink in the $PATH directory points to a location determined by Electron (via app.getPath( 'exe' )), which returns different values depending on whether the app is packaged.

We could indeed add some custom logic that checks the typical production return value of app.getPath( 'exe' ) in the development version of the app.

After mulling it over, I think this is a good idea. It doesn't affect end users, but it reduces the likelihood that developers will unintentionally affect their environment.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for considering this. I think it will lead to better dev UX, since the UI won't have different states in dev and production modes.

The warning displayed when trying to save the option when running dev mode is clear.

Small nit: when the user decides to cancel the save, the settings dialog becomes closed. It could be a better UX to keep it open.

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 @@ -86,10 +92,7 @@ async function installCLI(): Promise< void > {
}
}

const currentSymlinkDestination = await getCurrentSymlinkDestination();

// The CLI is already installed.
if ( currentSymlinkDestination === cliPackagedPath ) {
if ( await isCliInstalled() ) {
return;
}

Expand All @@ -112,6 +115,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