diff --git a/apps/cli/commands/blueprint/use.ts b/apps/cli/commands/blueprint/use.ts
index f5b04e8bd1..a721b94db4 100644
--- a/apps/cli/commands/blueprint/use.ts
+++ b/apps/cli/commands/blueprint/use.ts
@@ -18,6 +18,11 @@ import { __, _n, sprintf } from '@wordpress/i18n';
import { runCommand as runCreateSiteCommand } from 'cli/commands/site/create';
import { getDefaultSitePath } from 'cli/lib/site-paths';
import { untildify } from 'cli/lib/utils';
+import {
+ CLI_AUTO_UPDATE_WP_VERSION,
+ coerceWpVersionOption,
+ getWpVersionOptionDescription,
+} from 'cli/lib/wp-version-option';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';
@@ -162,8 +167,9 @@ export const registerCommand = ( yargs: StudioArgv ) => {
} )
.option( 'wp', {
type: 'string',
- describe: __( 'WordPress version' ),
- defaultDescription: DEFAULT_WORDPRESS_VERSION,
+ describe: getWpVersionOptionDescription(),
+ defaultDescription: CLI_AUTO_UPDATE_WP_VERSION,
+ coerce: coerceWpVersionOption,
} )
.option( 'php', {
type: 'string',
diff --git a/apps/cli/commands/config/set.ts b/apps/cli/commands/config/set.ts
index 55e9f208a4..3e9bb0dbe7 100644
--- a/apps/cli/commands/config/set.ts
+++ b/apps/cli/commands/config/set.ts
@@ -1,4 +1,4 @@
-import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants';
+import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants';
import { SITE_EVENTS } from '@studio/common/lib/cli-events';
import { getDomainNameValidationError } from '@studio/common/lib/domains';
import { arePathsEqual } from '@studio/common/lib/fs-utils';
@@ -24,11 +24,7 @@ import {
siteRuntimeFromMode,
type SiteMode,
} from '@studio/common/lib/site-runtime';
-import {
- getWordPressVersionUrl,
- isValidWordPressVersion,
- isWordPressVersionAtLeast,
-} from '@studio/common/lib/wordpress-version-utils';
+import { getWordPressVersionUrl } from '@studio/common/lib/wordpress-version-utils';
import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import { SupportedPHPVersions } from '@studio/common/types/php-versions';
import { __, sprintf } from '@wordpress/i18n';
@@ -46,12 +42,12 @@ import { validateSupportedPhpVersion } from 'cli/lib/php-versions';
import { runWpCliCommand } from 'cli/lib/run-wp-cli-command';
import { withSiteOperation } from 'cli/lib/site-operations';
import { setupCustomDomain } from 'cli/lib/site-utils';
-import { ValidationError } from 'cli/lib/validation-error';
import {
isServerRunning,
startWordPressServer,
stopWordPressServer,
} from 'cli/lib/wordpress-server-manager';
+import { coerceWpVersionOption, getWpVersionOptionDescription } from 'cli/lib/wp-version-option';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';
@@ -423,28 +419,8 @@ export const registerCommand = ( yargs: StudioArgv ) => {
} )
.option( 'wp', {
type: 'string',
- description: __(
- 'WordPress version. Use "latest" to let the site auto-update, or pin a version (e.g., "6.4", "6.4.1")'
- ),
- coerce: ( value: string ) => {
- if ( ! isValidWordPressVersion( value ) ) {
- throw new ValidationError(
- 'wp',
- value,
- __(
- 'Must be: "latest", "nightly", or a valid version number (e.g., "6.4", "6.4.1", "6.4-beta1")'
- )
- );
- }
- if ( ! isWordPressVersionAtLeast( value, MINIMUM_WORDPRESS_VERSION ) ) {
- throw new ValidationError(
- 'wp',
- value,
- sprintf( __( 'Must be: at least %s' ), MINIMUM_WORDPRESS_VERSION )
- );
- }
- return value;
- },
+ description: getWpVersionOptionDescription(),
+ coerce: coerceWpVersionOption,
} )
.option( 'runtime', {
type: 'string',
diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts
index 0f30bf4335..b141f19362 100644
--- a/apps/cli/commands/site/create.ts
+++ b/apps/cli/commands/site/create.ts
@@ -2,7 +2,7 @@ import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { confirm, input, password, select } from '@inquirer/prompts';
-import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants';
+import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants';
import { installAiInstructionsToSite } from '@studio/common/lib/agent-skills';
import {
createBlueprintTempDirSync,
@@ -53,10 +53,6 @@ import {
} from '@studio/common/lib/site-runtime';
import { sortSites } from '@studio/common/lib/sort-sites';
import { getServerFilesPath } from '@studio/common/lib/well-known-paths';
-import {
- isValidWordPressVersion,
- isWordPressVersionAtLeast,
-} from '@studio/common/lib/wordpress-version-utils';
import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions';
import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import {
@@ -98,6 +94,11 @@ import { StatsGroup } from 'cli/lib/types/bump-stats';
import { untildify } from 'cli/lib/utils';
import { ValidationError } from 'cli/lib/validation-error';
import { runBlueprint, startWordPressServer } from 'cli/lib/wordpress-server-manager';
+import {
+ CLI_AUTO_UPDATE_WP_VERSION,
+ coerceWpVersionOption,
+ getWpVersionOptionDescription,
+} from 'cli/lib/wp-version-option';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';
@@ -1110,28 +1111,6 @@ function coerceSiteId( value: string ) {
return value;
}
-function coerceWpVersion( value: string ) {
- if ( ! isValidWordPressVersion( value ) ) {
- throw new ValidationError(
- 'wp',
- value,
- __(
- 'Must be: "latest", "nightly", or a valid version number (e.g., "6.4", "6.4.1", "6.4-beta1")'
- )
- );
- }
-
- if ( ! isWordPressVersionAtLeast( value, MINIMUM_WORDPRESS_VERSION ) ) {
- throw new ValidationError(
- 'wp',
- value,
- sprintf( __( 'Must be: at least %s' ), MINIMUM_WORDPRESS_VERSION )
- );
- }
-
- return value;
-}
-
export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'create',
@@ -1150,11 +1129,9 @@ export const registerCommand = ( yargs: StudioArgv ) => {
} )
.option( 'wp', {
type: 'string',
- describe: __(
- 'WordPress version. Use "latest" to let the site auto-update, or pin a version (e.g., "6.4", "6.4.1")'
- ),
- defaultDescription: DEFAULT_WORDPRESS_VERSION,
- coerce: coerceWpVersion,
+ describe: getWpVersionOptionDescription(),
+ defaultDescription: CLI_AUTO_UPDATE_WP_VERSION,
+ coerce: coerceWpVersionOption,
} )
.option( 'php', {
type: 'string',
@@ -1379,15 +1356,18 @@ export const registerCommand = ( yargs: StudioArgv ) => {
try {
const versions = await fetchWordPressVersions();
wpChoices = versions.map( ( v ) => ( {
- name: v.value === 'latest' ? `latest (${ v.label })` : v.label,
+ name:
+ v.value === DEFAULT_WORDPRESS_VERSION
+ ? sprintf( __( 'Auto-update (%s)' ), v.label )
+ : v.label,
value: v.value,
} ) );
} catch {
- // Offline or API failure — offer only "latest"
+ // Offline or API failure — offer only the auto-update mode
wpChoices = [
{
- name: __( 'Latest (recommended)' ),
- value: 'latest',
+ name: __( 'Auto-update (recommended)' ),
+ value: DEFAULT_WORDPRESS_VERSION,
},
];
}
diff --git a/apps/cli/lib/tests/wp-version-option.test.ts b/apps/cli/lib/tests/wp-version-option.test.ts
new file mode 100644
index 0000000000..bd6467a544
--- /dev/null
+++ b/apps/cli/lib/tests/wp-version-option.test.ts
@@ -0,0 +1,46 @@
+import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants';
+import { ValidationError } from 'cli/lib/validation-error';
+import {
+ CLI_AUTO_UPDATE_WP_VERSION,
+ coerceWpVersionOption,
+ normalizeCliWpVersion,
+} from 'cli/lib/wp-version-option';
+
+describe( 'normalizeCliWpVersion', () => {
+ it( 'resolves the auto-update alias to the internal mode value', () => {
+ expect( normalizeCliWpVersion( CLI_AUTO_UPDATE_WP_VERSION ) ).toBe( DEFAULT_WORDPRESS_VERSION );
+ } );
+
+ it( 'passes every other value through untouched', () => {
+ for ( const value of [ 'latest', 'nightly', '6.4', '6.4.1', '6.4-beta1' ] ) {
+ expect( normalizeCliWpVersion( value ) ).toBe( value );
+ }
+ } );
+} );
+
+describe( 'coerceWpVersionOption', () => {
+ it( 'accepts the auto-update value and returns the internal mode', () => {
+ expect( coerceWpVersionOption( CLI_AUTO_UPDATE_WP_VERSION ) ).toBe( DEFAULT_WORDPRESS_VERSION );
+ } );
+
+ it( 'still accepts `latest`, so existing scripts keep working', () => {
+ expect( coerceWpVersionOption( 'latest' ) ).toBe( DEFAULT_WORDPRESS_VERSION );
+ } );
+
+ it( 'accepts pinned versions and nightly', () => {
+ expect( coerceWpVersionOption( '6.4.1' ) ).toBe( '6.4.1' );
+ expect( coerceWpVersionOption( 'nightly' ) ).toBe( 'nightly' );
+ } );
+
+ it( 'rejects an unknown value', () => {
+ expect( () => coerceWpVersionOption( 'auto' ) ).toThrow( ValidationError );
+ } );
+
+ it( 'rejects a version below the supported minimum', () => {
+ expect( () => coerceWpVersionOption( '4.9' ) ).toThrow( ValidationError );
+ } );
+
+ it( 'reports the value the user typed, not the resolved one', () => {
+ expect( () => coerceWpVersionOption( 'auto-updates' ) ).toThrow( /auto-updates/ );
+ } );
+} );
diff --git a/apps/cli/lib/wp-version-option.ts b/apps/cli/lib/wp-version-option.ts
new file mode 100644
index 0000000000..8b9d40ab60
--- /dev/null
+++ b/apps/cli/lib/wp-version-option.ts
@@ -0,0 +1,63 @@
+import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants';
+import {
+ isValidWordPressVersion,
+ isWordPressVersionAtLeast,
+} from '@studio/common/lib/wordpress-version-utils';
+import { __, sprintf } from '@wordpress/i18n';
+import { ValidationError } from 'cli/lib/validation-error';
+
+/**
+ * CLI-facing name for the auto-update mode, matching the "Auto-update" option in
+ * the apps.
+ *
+ * `latest` is the legacy spelling. It stays the internal value — it names a cache
+ * directory, a segment of the wordpress.org download URL, and the persisted
+ * `wpVersion` — so `auto-update` is resolved to it here, at the boundary, and
+ * never reaches storage. Passing `latest` keeps working for existing scripts.
+ */
+export const CLI_AUTO_UPDATE_WP_VERSION = 'auto-update';
+
+export function normalizeCliWpVersion( value: string ): string {
+ return value === CLI_AUTO_UPDATE_WP_VERSION ? DEFAULT_WORDPRESS_VERSION : value;
+}
+
+/** Description for the `--wp` option, shared so both commands read alike. */
+export function getWpVersionOptionDescription(): string {
+ return sprintf(
+ /* translators: 1: the CLI value "auto-update". 2: the legacy CLI value "latest". Do not translate either. */
+ __(
+ 'WordPress version. Use "%1$s" to let the site auto-update, or pin a version (e.g., "6.4", "6.4.1"). Replaces the legacy "%2$s" option, which still works.'
+ ),
+ CLI_AUTO_UPDATE_WP_VERSION,
+ DEFAULT_WORDPRESS_VERSION
+ );
+}
+
+/** Resolves the auto-update alias, then validates. Returns the internal value. */
+export function coerceWpVersionOption( value: string ): string {
+ const version = normalizeCliWpVersion( value );
+
+ if ( ! isValidWordPressVersion( version ) ) {
+ throw new ValidationError(
+ 'wp',
+ value,
+ sprintf(
+ /* translators: %s: the literal CLI value "auto-update", do not translate. */
+ __(
+ 'Must be: "%s", "nightly", or a valid version number (e.g., "6.4", "6.4.1", "6.4-beta1")'
+ ),
+ CLI_AUTO_UPDATE_WP_VERSION
+ )
+ );
+ }
+
+ if ( ! isWordPressVersionAtLeast( version, MINIMUM_WORDPRESS_VERSION ) ) {
+ throw new ValidationError(
+ 'wp',
+ value,
+ sprintf( __( 'Must be: at least %s' ), MINIMUM_WORDPRESS_VERSION )
+ );
+ }
+
+ return version;
+}
diff --git a/apps/studio/src/components/settings-section.tsx b/apps/studio/src/components/settings-section.tsx
new file mode 100644
index 0000000000..c97f02c60f
--- /dev/null
+++ b/apps/studio/src/components/settings-section.tsx
@@ -0,0 +1,25 @@
+import { cx } from 'src/lib/cx';
+import type { ReactNode } from 'react';
+
+/**
+ * One titled group of settings, separated from the previous group by a rule.
+ * The first section omits the rule so a form doesn't open with a stray line.
+ */
+export function SettingsSection( {
+ title,
+ isFirst = false,
+ children,
+}: {
+ title: string;
+ isFirst?: boolean;
+ children: ReactNode;
+} ) {
+ return (
+
+
{ title }
+ { children }
+
+ );
+}
diff --git a/apps/studio/src/components/wp-version-selector/index.tsx b/apps/studio/src/components/wp-version-selector/index.tsx
index 0d3b9dea74..17e62d8af7 100644
--- a/apps/studio/src/components/wp-version-selector/index.tsx
+++ b/apps/studio/src/components/wp-version-selector/index.tsx
@@ -1,15 +1,19 @@
import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants';
-import { getAutoUpdateVersionLabel } from '@studio/common/lib/wordpress-version-labels';
+import {
+ getAutomaticUpdatesDescription,
+ getAutomaticUpdatesLabel,
+ getSelectAVersionLabel,
+} from '@studio/common/lib/wordpress-version-labels';
import { isWordPressBetaVersion } from '@studio/common/lib/wordpress-version-utils';
import { SelectControl, Icon } from '@wordpress/components';
import { info } from '@wordpress/icons';
import { useI18n } from '@wordpress/react-i18n';
-import { useEffect } from 'react';
+import { useEffect, useId } from 'react';
import offlineIcon from 'src/components/offline-icon';
import { Tooltip } from 'src/components/tooltip';
import { useOffline } from 'src/hooks/use-offline';
import { cx } from 'src/lib/cx';
-import { isWordPressDevVersion } from 'src/lib/version-utils';
+import { getLatestStableWpVersion, isWordPressDevVersion } from 'src/lib/version-utils';
import { useGetWordPressVersions } from 'src/stores/wordpress-versions-api';
import { addWpVersionToList } from './add-wp-version-to-list';
@@ -40,6 +44,9 @@ export const WPVersionSelector = ( {
}: WPVersionSelectorProps ) => {
const { __ } = useI18n();
const isOffline = useOffline();
+ // Unique per instance, so two selectors on one screen keep their own radio
+ // group and their own label/description associations.
+ const modeControlName = useId();
const defaultOfflineMessage = __( 'Changing WordPress version requires an internet connection.' );
const message = offlineMessage || defaultOfflineMessage;
const { data: wpVersions = [] } = useGetWordPressVersions( {
@@ -76,11 +83,137 @@ export const WPVersionSelector = ( {
}
} );
+ // Without a fetched version list there is nothing to pin to, so the control
+ // degrades to a plain dropdown over `fallbackOptions`.
+ const usesUpdateMode = wpVersions.length > 0;
+ const automaticUpdates = selectedValue === DEFAULT_WORDPRESS_VERSION;
+ // Leaving auto-update lands on the version the site already runs, or on the
+ // newest stable release for a site that doesn't exist yet.
+ const pinnedFallback =
+ autoUpdateVersion || getLatestStableWpVersion( wpVersions ) || DEFAULT_WORDPRESS_VERSION;
+
+ // A function, not an element: auto-update is the default in both forms, and
+ // there the dropdown is never rendered.
+ const renderVersionSelect = () => (
+ already names the control.
+ label={ usesUpdateMode ? __( 'Version' ) : undefined }
+ hideLabelFromVision={ usesUpdateMode }
+ disabled={ disabled || isOffline }
+ // While "Automatic updates" is chosen the value is the mode, not a
+ // version, so preview the version the radio below would pin to.
+ value={ usesUpdateMode && automaticUpdates ? pinnedFallback : selectedValue }
+ onChange={ onChange }
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ >
+ { usesUpdateMode ? (
+ <>
+
+
+ >
+ ) : (
+ fallbackOptions.map( ( { label, value } ) => (
+
+ ) )
+ ) }
+
+ );
+
+ if ( usesUpdateMode ) {
+ const automaticId = `${ modeControlName }-automatic`;
+ const automaticDescriptionId = `${ automaticId }-description`;
+ const pinnedId = `${ modeControlName }-pinned`;
+ return (
+
+ { __( 'WordPress version' ) }
+
+ { /* `.components-radio-control` sets no gap of its own, so the first
+ option's description would butt against the second radio.
+ 16px matches the agentic UI's --wpds-dimension-padding-lg. */ }
+
+ { /* Naming a version on a pinned site would read as if
+ auto-update were keeping it there. */ }
+ { getAutomaticUpdatesDescription(
+ automaticUpdates ? autoUpdateVersion : undefined
+ ) }
+
+
+
+ onChange( pinnedFallback ) }
+ />
+
+
+ { renderVersionSelect() }
+
+
+
+
+
+ );
+ }
+
return (
);
diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css
index d9839371fb..b546b863e2 100644
--- a/apps/studio/src/index.css
+++ b/apps/studio/src/index.css
@@ -616,6 +616,18 @@ div:has( > progress ) > div:first-child {
color: var( --color-frame-text-secondary );
}
+ /* Radio — the update-mode control is the app's only radio group, so the
+ unchecked state has no dark treatment from anywhere else. The `p` rule
+ above paints every paragraph in primary text, which would make the option
+ descriptions read as body copy. */
+ .components-radio-control__input[type='radio']:not( :checked ) {
+ border-color: var( --color-frame-border );
+ background-color: var( --color-frame-surface );
+ }
+ .components-radio-control__option-description {
+ color: var( --color-frame-text-secondary );
+ }
+
/* Checkbox / Toggle */
.components-checkbox-control__input[type='checkbox'],
input[type='checkbox'] {
diff --git a/apps/studio/src/modules/add-site/components/create-site-form.tsx b/apps/studio/src/modules/add-site/components/create-site-form.tsx
index 72a78ed9a5..d7df319bc4 100644
--- a/apps/studio/src/modules/add-site/components/create-site-form.tsx
+++ b/apps/studio/src/modules/add-site/components/create-site-form.tsx
@@ -1,4 +1,4 @@
-import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants';
+import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants';
import {
generateCustomDomainFromSiteName,
getDomainNameValidationError,
@@ -19,7 +19,6 @@ import {
type SiteRuntime,
} from '@studio/common/lib/site-runtime';
import { getAutoUpdateVersionLabel } from '@studio/common/lib/wordpress-version-labels';
-import { getLatestVersionLabel } from '@studio/common/lib/wordpress-versions';
import {
RecommendedPHPVersion,
SupportedPHPVersion,
@@ -35,13 +34,13 @@ import Button from 'src/components/button';
import { FormPathInputComponent } from 'src/components/form-path-input';
import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more';
import PasswordControl from 'src/components/password-control';
+import { SettingsSection } from 'src/components/settings-section';
import { SiteFormError } from 'src/components/site-form-error';
import TextControlComponent from 'src/components/text-control';
import { WPVersionSelector } from 'src/components/wp-version-selector';
import { cx } from 'src/lib/cx';
import { FileAccessDescription, RuntimeDescription } from 'src/lib/site-runtime-copy';
import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api';
-import { useGetWordPressVersions } from 'src/stores/wordpress-versions-api';
import type { BlueprintPreferredVersions } from '@studio/common/lib/blueprint-validation';
import type { CreateSiteFormValues, PathValidationResult } from 'src/hooks/use-add-site';
@@ -93,9 +92,6 @@ export const CreateSiteForm = ( {
}: CreateSiteFormProps ) => {
const { __, isRTL } = useI18n();
const { data: isCertificateTrusted } = useCheckCertificateTrustQuery();
- const { data: wpVersions } = useGetWordPressVersions( {
- minimumVersion: MINIMUM_WORDPRESS_VERSION,
- } );
const [ siteName, setSiteName ] = useState( defaultValues.siteName ?? '' );
const [ sitePath, setSitePath ] = useState( defaultValues.sitePath ?? '' );
const [ phpVersion, setPhpVersion ] = useState< SupportedPHPVersion >(
@@ -477,32 +473,50 @@ export const CreateSiteForm = ( {
isAdvancedSettingsVisible ? 'h-auto opacity-100' : 'h-0 opacity-0'
) }
>
-
-
-
- { createInterpolateElement(
- __(
- 'Select an empty directory or a directory with an existing WordPress site. '
- ),
- {
- learn_more_link: ,
+
+
+
+
+ { createInterpolateElement(
+ __(
+ 'Select an empty directory or a directory with an existing WordPress site. '
+ ),
+ {
+ learn_more_link: ,
+ }
+ ) }
+
+
-
-