From 11a04035160283a2485e0c2e72e39455cf7a04dd Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 27 Oct 2026 13:13:51 -0400 Subject: [PATCH 1/2] CLI: Load each command's implementation when the command runs --- apps/cli/commands/export.command.ts | 67 ++ apps/cli/commands/export.ts | 144 ++--- apps/cli/commands/import.command.ts | 39 ++ apps/cli/commands/import.ts | 58 +- apps/cli/commands/mcp.command.ts | 18 + apps/cli/commands/mcp.ts | 45 +- apps/cli/commands/pull-reprint.command.ts | 41 ++ apps/cli/commands/pull-reprint.ts | 80 +-- apps/cli/commands/pull.command.ts | 47 ++ apps/cli/commands/pull.ts | 106 +--- apps/cli/commands/push.command.ts | 41 ++ apps/cli/commands/push.ts | 74 +-- apps/cli/commands/site/create.command.ts | 186 ++++++ apps/cli/commands/site/create.ts | 656 ++++++++------------ apps/cli/commands/site/delete.command.ts | 40 ++ apps/cli/commands/site/delete.ts | 69 +- apps/cli/commands/site/list.command.ts | 28 + apps/cli/commands/site/list.ts | 47 +- apps/cli/commands/site/start.command.ts | 28 + apps/cli/commands/site/start.ts | 47 +- apps/cli/commands/site/status.command.ts | 23 + apps/cli/commands/site/status.ts | 42 +- apps/cli/commands/site/stop.command.ts | 22 + apps/cli/commands/site/stop.ts | 55 +- apps/cli/commands/site/tests/create.test.ts | 3 +- apps/cli/commands/tests/export.test.ts | 3 +- apps/cli/commands/ui.command.ts | 27 + apps/cli/commands/ui.ts | 134 ++-- apps/cli/commands/uninstall.command.ts | 23 + apps/cli/commands/uninstall.ts | 24 +- apps/cli/index.ts | 28 +- apps/cli/tests/startup-imports.test.ts | 17 +- apps/cli/types.ts | 6 +- 33 files changed, 1207 insertions(+), 1061 deletions(-) create mode 100644 apps/cli/commands/export.command.ts create mode 100644 apps/cli/commands/import.command.ts create mode 100644 apps/cli/commands/mcp.command.ts create mode 100644 apps/cli/commands/pull-reprint.command.ts create mode 100644 apps/cli/commands/pull.command.ts create mode 100644 apps/cli/commands/push.command.ts create mode 100644 apps/cli/commands/site/create.command.ts create mode 100644 apps/cli/commands/site/delete.command.ts create mode 100644 apps/cli/commands/site/list.command.ts create mode 100644 apps/cli/commands/site/start.command.ts create mode 100644 apps/cli/commands/site/status.command.ts create mode 100644 apps/cli/commands/site/stop.command.ts create mode 100644 apps/cli/commands/ui.command.ts create mode 100644 apps/cli/commands/uninstall.command.ts diff --git a/apps/cli/commands/export.command.ts b/apps/cli/commands/export.command.ts new file mode 100644 index 0000000000..f7ad699784 --- /dev/null +++ b/apps/cli/commands/export.command.ts @@ -0,0 +1,67 @@ +import path from 'path'; +import { __ } from '@wordpress/i18n'; +import { untildify } from 'cli/lib/utils'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( yargs: StudioArgv ) => { + return yargs + .positional( 'export-file', { + type: 'string', + normalize: true, + demandOption: false, + description: __( + 'Path to the export file. All exports can use .zip or .tar.gz. Database-only exports can also use .sql.' + ), + coerce: ( value ) => { + return path.resolve( untildify( value ) ); + }, + } ) + .option( 'mode', { + type: 'string', + choices: [ 'full', 'content', 'db' ] as const, + default: 'full' as const, + description: __( + 'Export the full site, just the content, or just the database. Default exports full site.' + ), + } ) + .option( 'split-db-dump-by-table', { + type: 'boolean', + default: false, + description: __( 'Split the database dump by table' ), + hidden: true, + } ) + .option( 'include-only', { + type: 'array', + description: __( 'Include only the specified paths in the export' ), + coerce: ( value ) => { + if ( ! Array.isArray( value ) ) { + throw new Error( __( 'include-only must be an array' ) ); + } + return value.map( String ); + }, + hidden: true, + } ) + .option( 'apply-deploy-ignore', { + type: 'boolean', + default: false, + description: __( 'Apply .deployignore patterns when exporting' ), + hidden: true, + } ) + .option( 'suppress-tracks-event', { + type: 'boolean', + default: false, + hidden: true, + } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'export [export-file]', + describe: __( 'Export site to a backup file' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/export' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/export.ts b/apps/cli/commands/export.ts index 525cfa9e1c..9153d5ac25 100644 --- a/apps/cli/commands/export.ts +++ b/apps/cli/commands/export.ts @@ -13,9 +13,10 @@ import { ExportOptions } from 'cli/lib/import-export/export/types'; import { withSiteOperation } from 'cli/lib/site-operations'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; import { getTracksOrigin, recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/tracks'; -import { classifyExportFailure, untildify } from 'cli/lib/utils'; +import { classifyExportFailure } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; +import type { builder } from 'cli/commands/export.command'; +import type { CommandArgs } from 'cli/types'; const defaultLogger = new Logger< LoggerAction >(); @@ -261,103 +262,44 @@ function getTimestamp( date = new Date() ): string { .map( ( part ) => String( part ).padStart( 2, '0' ) ) .join( '-' ); } +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + try { + let exportFile: string; + const timestamp = getTimestamp(); -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'export [export-file]', - describe: __( 'Export site to a backup file' ), - builder: ( yargs ) => { - return yargs - .positional( 'export-file', { - type: 'string', - normalize: true, - demandOption: false, - description: __( - 'Path to the export file. All exports can use .zip or .tar.gz. Database-only exports can also use .sql.' - ), - coerce: ( value ) => { - return path.resolve( untildify( value ) ); - }, - } ) - .option( 'mode', { - type: 'string', - choices: [ 'full', 'content', 'db' ] as const, - default: 'full' as const, - description: __( - 'Export the full site, just the content, or just the database. Default exports full site.' - ), - } ) - .option( 'split-db-dump-by-table', { - type: 'boolean', - default: false, - description: __( 'Split the database dump by table' ), - hidden: true, - } ) - .option( 'include-only', { - type: 'array', - description: __( 'Include only the specified paths in the export' ), - coerce: ( value ) => { - if ( ! Array.isArray( value ) ) { - throw new Error( __( 'include-only must be an array' ) ); - } - return value.map( String ); - }, - hidden: true, - } ) - .option( 'apply-deploy-ignore', { - type: 'boolean', - default: false, - description: __( 'Apply .deployignore patterns when exporting' ), - hidden: true, - } ) - .option( 'suppress-tracks-event', { - type: 'boolean', - default: false, - hidden: true, - } ); - }, - handler: async ( argv ) => { - try { - let exportFile: string; - const timestamp = getTimestamp(); - - if ( argv.exportFile ) { - exportFile = argv.exportFile; - } else if ( argv.mode === 'full' || argv.mode === 'content' ) { - exportFile = path.join( process.cwd(), `studio-backup-${ timestamp }.zip` ); - } else { - exportFile = path.join( process.cwd(), `studio-backup-${ timestamp }.sql` ); - } - - if ( - ( argv.mode === 'full' || argv.mode === 'content' ) && - ! exportFile.endsWith( '.zip' ) && - ! exportFile.endsWith( '.tar.gz' ) - ) { - throw new LoggerError( - __( - 'Invalid export file extension. Must be .zip or .tar.gz when exporting the full site.' - ) - ); - } - - await runCommand( - argv.path, - exportFile, - argv.mode, - argv.splitDbDumpByTable, - argv.includeOnly, - argv.applyDeployIgnore, - argv.suppressTracksEvent - ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - defaultLogger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to export site' ), error ); - defaultLogger.reportError( loggerError ); - } - } - }, - } ); -}; + if ( argv.exportFile ) { + exportFile = argv.exportFile; + } else if ( argv.mode === 'full' || argv.mode === 'content' ) { + exportFile = path.join( process.cwd(), `studio-backup-${ timestamp }.zip` ); + } else { + exportFile = path.join( process.cwd(), `studio-backup-${ timestamp }.sql` ); + } + + if ( + ( argv.mode === 'full' || argv.mode === 'content' ) && + ! exportFile.endsWith( '.zip' ) && + ! exportFile.endsWith( '.tar.gz' ) + ) { + throw new LoggerError( + __( 'Invalid export file extension. Must be .zip or .tar.gz when exporting the full site.' ) + ); + } + + await runCommand( + argv.path, + exportFile, + argv.mode, + argv.splitDbDumpByTable, + argv.includeOnly, + argv.applyDeployIgnore, + argv.suppressTracksEvent + ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + defaultLogger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to export site' ), error ); + defaultLogger.reportError( loggerError ); + } + } +} diff --git a/apps/cli/commands/import.command.ts b/apps/cli/commands/import.command.ts new file mode 100644 index 0000000000..22ecee48e2 --- /dev/null +++ b/apps/cli/commands/import.command.ts @@ -0,0 +1,39 @@ +import path from 'path'; +import { __ } from '@wordpress/i18n'; +import { untildify } from 'cli/lib/utils'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( yargs: StudioArgv ) => { + return yargs + .positional( 'import-file', { + type: 'string', + normalize: true, + demandOption: true, + description: __( 'Path to the import file' ), + coerce: ( value ) => { + return path.resolve( untildify( value ) ); + }, + } ) + .option( 'start-server', { + type: 'boolean', + default: false, + hidden: true, + } ) + .option( 'suppress-tracks-event', { + type: 'boolean', + default: false, + hidden: true, + } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'import ', + describe: __( 'Import a backup file to site' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/import' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/import.ts b/apps/cli/commands/import.ts index 621a3892a1..d5257c66b8 100644 --- a/apps/cli/commands/import.ts +++ b/apps/cli/commands/import.ts @@ -28,14 +28,15 @@ import { withSiteOperation } from 'cli/lib/site-operations'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; import { reportSyncActivity } from 'cli/lib/sync-activity'; import { getTracksOrigin, recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/tracks'; -import { classifyImportFailure, untildify } from 'cli/lib/utils'; +import { classifyImportFailure } from 'cli/lib/utils'; import { isServerRunning, startWordPressServer, stopWordPressServer, } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; +import type { builder } from 'cli/commands/import.command'; +import type { CommandArgs } from 'cli/types'; const defaultLogger = new Logger< LoggerAction >(); @@ -447,44 +448,15 @@ async function recordSiteImportEvent( props: { // Best-effort telemetry — never block or fail the import. } } - -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'import ', - describe: __( 'Import a backup file to site' ), - builder: ( yargs ) => { - return yargs - .positional( 'import-file', { - type: 'string', - normalize: true, - demandOption: true, - description: __( 'Path to the import file' ), - coerce: ( value ) => { - return path.resolve( untildify( value ) ); - }, - } ) - .option( 'start-server', { - type: 'boolean', - default: false, - hidden: true, - } ) - .option( 'suppress-tracks-event', { - type: 'boolean', - default: false, - hidden: true, - } ); - }, - handler: async ( argv ) => { - try { - await runCommand( argv.path, argv.importFile, argv.startServer, argv.suppressTracksEvent ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - defaultLogger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to import site' ), error ); - defaultLogger.reportError( loggerError ); - } - } - }, - } ); -}; +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + try { + await runCommand( argv.path, argv.importFile, argv.startServer, argv.suppressTracksEvent ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + defaultLogger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to import site' ), error ); + defaultLogger.reportError( loggerError ); + } + } +} diff --git a/apps/cli/commands/mcp.command.ts b/apps/cli/commands/mcp.command.ts new file mode 100644 index 0000000000..8a6a66454e --- /dev/null +++ b/apps/cli/commands/mcp.command.ts @@ -0,0 +1,18 @@ +import { __ } from '@wordpress/i18n'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( yargs: StudioArgv ) => { + return yargs.help( false ).option( 'help', { type: 'boolean' } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'mcp', + describe: __( 'MCP server for AI agents' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/mcp' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/mcp.ts b/apps/cli/commands/mcp.ts index a35f171703..b6e12a4973 100644 --- a/apps/cli/commands/mcp.ts +++ b/apps/cli/commands/mcp.ts @@ -5,7 +5,8 @@ import { } from '@studio/common/lib/mcp-config'; import { __ } from '@wordpress/i18n'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; +import type { builder } from 'cli/commands/mcp.command'; +import type { CommandArgs } from 'cli/types'; const logger = new Logger< string >(); @@ -73,29 +74,19 @@ export async function runCommand(): Promise< void > { const { startMcpStdioServer } = await import( 'cli/ai/runtimes/mcp' ); await startMcpStdioServer(); } - -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'mcp', - describe: __( 'MCP server for AI agents' ), - builder: ( yargs ) => { - return yargs.help( false ).option( 'help', { type: 'boolean' } ); - }, - handler: async ( argv ) => { - if ( argv.help ) { - printInstallationInstructions(); - return; - } - try { - await runCommand(); - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'MCP server failed' ), error ); - logger.reportError( loggerError ); - } - } - }, - } ); -}; +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + if ( argv.help ) { + printInstallationInstructions(); + return; + } + try { + await runCommand(); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'MCP server failed' ), error ); + logger.reportError( loggerError ); + } + } +} diff --git a/apps/cli/commands/pull-reprint.command.ts b/apps/cli/commands/pull-reprint.command.ts new file mode 100644 index 0000000000..6a2bade56e --- /dev/null +++ b/apps/cli/commands/pull-reprint.command.ts @@ -0,0 +1,41 @@ +import { __ } from '@wordpress/i18n'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( builderYargs: StudioArgv ) => { + return builderYargs + .option( 'url', { + type: 'string', + describe: __( 'URL of the remote WordPress site to pull from (remote source)' ), + } ) + .option( 'only', { + type: 'string', + array: true, + describe: __( + 'Restrict the pull to specific wp-content files or folders (e.g. plugins/akismet, themes, uploads/index.php); repeatable.' + ), + } ) + .option( 'skip-database', { + type: 'boolean', + describe: __( 'Do not pull the database (keeps the local one)' ), + default: false, + } ) + .option( 'verbose', { + type: 'boolean', + describe: __( 'Show detailed error information and executed commands' ), + default: false, + } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'pull-reprint', + describe: __( + 'Pull a remote WordPress site into an existing local site (run `studio create` first; remove with `studio delete`)' + ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/pull-reprint' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/pull-reprint.ts b/apps/cli/commands/pull-reprint.ts index 7ab54afe84..ff3a86ebb7 100644 --- a/apps/cli/commands/pull-reprint.ts +++ b/apps/cli/commands/pull-reprint.ts @@ -56,67 +56,35 @@ import { getProcessName, } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; import type { SyncSite } from '@studio/common/types/sync'; +import type { builder } from 'cli/commands/pull-reprint.command'; +import type { CommandArgs } from 'cli/types'; const logger = new Logger< LoggerAction >(); +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + const verbose = argv.verbose; -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'pull-reprint', - describe: __( - 'Pull a remote WordPress site into an existing local site (run `studio create` first; remove with `studio delete`)' - ), - builder: ( builderYargs ) => { - return builderYargs - .option( 'url', { - type: 'string', - describe: __( 'URL of the remote WordPress site to pull from (remote source)' ), - } ) - .option( 'only', { - type: 'string', - array: true, - describe: __( - 'Restrict the pull to specific wp-content files or folders (e.g. plugins/akismet, themes, uploads/index.php); repeatable.' - ), - } ) - .option( 'skip-database', { - type: 'boolean', - describe: __( 'Do not pull the database (keeps the local one)' ), - default: false, - } ) - .option( 'verbose', { - type: 'boolean', - describe: __( 'Show detailed error information and executed commands' ), - default: false, - } ); - }, - handler: async ( argv ) => { - const verbose = argv.verbose; - - try { - await runCommand( argv.path, argv.url, verbose, { - only: argv.only as string[] | undefined, - skipDatabase: argv[ 'skip-database' ] as boolean, - } ); - } catch ( error ) { - if ( error instanceof PullError ) { - logger.spinner.fail( __( 'Pull failed' ) ); - console.error( '\n' + chalk.bold.red( error.message ) ); - if ( verbose && error.technicalDetails ) { - console.error( '\n' + chalk.dim( error.technicalDetails ) ); - } else if ( error.technicalDetails ) { - console.error( chalk.dim( '\nRun with --verbose for detailed error output.' ) ); - } - } else if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - logger.reportError( new LoggerError( __( 'Failed to pull site' ), error ) ); - } + try { + await runCommand( argv.path, argv.url, verbose, { + only: argv.only as string[] | undefined, + skipDatabase: argv[ 'skip-database' ] as boolean, + } ); + } catch ( error ) { + if ( error instanceof PullError ) { + logger.spinner.fail( __( 'Pull failed' ) ); + console.error( '\n' + chalk.bold.red( error.message ) ); + if ( verbose && error.technicalDetails ) { + console.error( '\n' + chalk.dim( error.technicalDetails ) ); + } else if ( error.technicalDetails ) { + console.error( chalk.dim( '\nRun with --verbose for detailed error output.' ) ); } - }, - } ); -}; + } else if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Failed to pull site' ), error ) ); + } + } +} /** * Where Studio stores the working directories for each pulled diff --git a/apps/cli/commands/pull.command.ts b/apps/cli/commands/pull.command.ts new file mode 100644 index 0000000000..0b34179062 --- /dev/null +++ b/apps/cli/commands/pull.command.ts @@ -0,0 +1,47 @@ +import { __ } from '@wordpress/i18n'; +import { parseSyncOptions } from 'cli/lib/sync-api'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( yargs: StudioArgv ) => { + return yargs + .option( 'options', { + type: 'string', + description: __( + 'Comma-separated sync options: all, sqls, uploads, plugins, themes, contents' + ), + coerce: ( val: string | undefined ) => + val !== undefined ? parseSyncOptions( val ) : undefined, + } ) + .option( 'remote-site', { + type: 'string', + description: __( 'Remote site URL or ID' ), + } ) + .option( 'include-path-list', { + type: 'array', + description: __( 'Backup node ids to pull when using the "paths" option' ), + hidden: true, + coerce: ( value ) => { + if ( ! Array.isArray( value ) ) { + throw new Error( __( 'include-path-list must be an array' ) ); + } + return value.map( String ); + }, + } ) + .option( 'suppress-tracks-event', { + type: 'boolean', + default: false, + hidden: true, + } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'pull', + describe: __( 'Pull a WordPress.com site to your local site' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/pull' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/pull.ts b/apps/cli/commands/pull.ts index 777ca4c47f..4b0eaf4867 100644 --- a/apps/cli/commands/pull.ts +++ b/apps/cli/commands/pull.ts @@ -26,7 +26,6 @@ import { checkBackupSize, fetchSyncableSites, initiateBackup, - parseSyncOptions, pollBackupStatus, downloadBackup, } from 'cli/lib/sync-api'; @@ -40,10 +39,11 @@ import { stopWordPressServer, } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; import { handleImportEvents } from './import'; import type { SyncEventProps } from '@studio/common/lib/sync/build-sync-event-props'; import type { SyncOption, SyncSite } from '@studio/common/types/sync'; +import type { builder } from 'cli/commands/pull.command'; +import type { CommandArgs } from 'cli/types'; const defaultLogger = new Logger< LoggerAction >(); @@ -334,73 +334,35 @@ async function recordSyncPullEvent( props: SyncEventProps ): Promise< void > { // Best-effort telemetry — never block or fail the pull. } } - -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'pull', - describe: __( 'Pull a WordPress.com site to your local site' ), - builder: ( yargs ) => { - return yargs - .option( 'options', { - type: 'string', - description: __( - 'Comma-separated sync options: all, sqls, uploads, plugins, themes, contents' - ), - coerce: ( val: string | undefined ) => - val !== undefined ? parseSyncOptions( val ) : undefined, - } ) - .option( 'remote-site', { - type: 'string', - description: __( 'Remote site URL or ID' ), - } ) - .option( 'include-path-list', { - type: 'array', - description: __( 'Backup node ids to pull when using the "paths" option' ), - hidden: true, - coerce: ( value ) => { - if ( ! Array.isArray( value ) ) { - throw new Error( __( 'include-path-list must be an array' ) ); - } - return value.map( String ); - }, - } ) - .option( 'suppress-tracks-event', { - type: 'boolean', - default: false, - hidden: true, - } ); - }, - handler: async ( argv ) => { - exitOnCancel(); - try { - await runCommand( - argv.path, - argv.options as SyncOption[] | undefined, - argv.remoteSite, - argv.includePathList as string[] | undefined, - defaultLogger, - argv.suppressTracksEvent, - process.stdin.isTTY - ? () => - confirm( { - message: sprintf( - __( - "Your site's backup exceeds %d GB. Pulling it will prevent you from pushing the site back. Do you want to continue?" - ), - SYNC_PUSH_SIZE_LIMIT_GB - ), - default: true, - } ) - : undefined - ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - defaultLogger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Pull failed' ), error ); - defaultLogger.reportError( loggerError ); - } - } - }, - } ); -}; +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + exitOnCancel(); + try { + await runCommand( + argv.path, + argv.options as SyncOption[] | undefined, + argv.remoteSite, + argv.includePathList as string[] | undefined, + defaultLogger, + argv.suppressTracksEvent, + process.stdin.isTTY + ? () => + confirm( { + message: sprintf( + __( + "Your site's backup exceeds %d GB. Pulling it will prevent you from pushing the site back. Do you want to continue?" + ), + SYNC_PUSH_SIZE_LIMIT_GB + ), + default: true, + } ) + : undefined + ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + defaultLogger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Pull failed' ), error ); + defaultLogger.reportError( loggerError ); + } + } +} diff --git a/apps/cli/commands/push.command.ts b/apps/cli/commands/push.command.ts new file mode 100644 index 0000000000..180ffde855 --- /dev/null +++ b/apps/cli/commands/push.command.ts @@ -0,0 +1,41 @@ +import { __ } from '@wordpress/i18n'; +import { parseSyncOptions } from 'cli/lib/sync-api'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( yargs: StudioArgv ) => { + return yargs + .option( 'options', { + type: 'string', + description: __( + 'Comma-separated sync options: all, sqls, uploads, plugins, themes, contents' + ), + coerce: ( val: string | undefined ) => + val !== undefined ? parseSyncOptions( val ) : undefined, + } ) + .option( 'remote-site', { + type: 'string', + description: __( 'Remote site URL or ID' ), + } ) + .option( 'include-only', { + type: 'string', + array: true, + description: __( 'Only push these paths from the selection made with --options' ), + } ) + .option( 'suppress-tracks-event', { + type: 'boolean', + default: false, + hidden: true, + } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'push', + describe: __( 'Push your local site to a WordPress.com site' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/push' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/push.ts b/apps/cli/commands/push.ts index ce18be86d0..b0b661076b 100644 --- a/apps/cli/commands/push.ts +++ b/apps/cli/commands/push.ts @@ -26,21 +26,17 @@ import { ExportOptions } from 'cli/lib/import-export/export/types'; import { withSiteOperation } from 'cli/lib/site-operations'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; import { exitOnCancel, reportSyncActivity } from 'cli/lib/sync-activity'; -import { - fetchSyncableSites, - initiateImport, - parseSyncOptions, - pollImportStatus, -} from 'cli/lib/sync-api'; +import { fetchSyncableSites, initiateImport, pollImportStatus } from 'cli/lib/sync-api'; import { selectSyncItemsForPush } from 'cli/lib/sync-selector'; import { findSyncSiteByIdentifier, pickSyncSite } from 'cli/lib/sync-site-picker'; import { getTracksOrigin, recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/tracks'; import { findFailureCode } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; import { handleExportEvents } from './export'; import type { SyncEventProps } from '@studio/common/lib/sync/build-sync-event-props'; import type { ImportResponse, PushPhase, SyncSite } from '@studio/common/types/sync'; +import type { builder } from 'cli/commands/push.command'; +import type { CommandArgs } from 'cli/types'; const defaultLogger = new Logger< LoggerAction >(); @@ -415,51 +411,19 @@ async function recordSyncPushEvent( props: SyncEventProps ): Promise< void > { // Best-effort telemetry — never block or fail the push. } } - -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'push', - describe: __( 'Push your local site to a WordPress.com site' ), - builder: ( yargs ) => { - return yargs - .option( 'options', { - type: 'string', - description: __( - 'Comma-separated sync options: all, sqls, uploads, plugins, themes, contents' - ), - coerce: ( val: string | undefined ) => - val !== undefined ? parseSyncOptions( val ) : undefined, - } ) - .option( 'remote-site', { - type: 'string', - description: __( 'Remote site URL or ID' ), - } ) - .option( 'include-only', { - type: 'string', - array: true, - description: __( 'Only push these paths from the selection made with --options' ), - } ) - .option( 'suppress-tracks-event', { - type: 'boolean', - default: false, - hidden: true, - } ); - }, - handler: async ( argv ) => { - exitOnCancel(); - try { - await runCommand( argv.path, argv.options, argv.remoteSite, defaultLogger, { - includeOnly: argv.includeOnly, - suppressTracksEvent: argv.suppressTracksEvent, - } ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - defaultLogger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Push failed' ), error ); - defaultLogger.reportError( loggerError ); - } - } - }, - } ); -}; +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + exitOnCancel(); + try { + await runCommand( argv.path, argv.options, argv.remoteSite, defaultLogger, { + includeOnly: argv.includeOnly, + suppressTracksEvent: argv.suppressTracksEvent, + } ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + defaultLogger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Push failed' ), error ); + defaultLogger.reportError( loggerError ); + } + } +} diff --git a/apps/cli/commands/site/create.command.ts b/apps/cli/commands/site/create.command.ts new file mode 100644 index 0000000000..d9dbcd4667 --- /dev/null +++ b/apps/cli/commands/site/create.command.ts @@ -0,0 +1,186 @@ +import fs from 'fs'; +import path from 'path'; +import { + SITE_FILE_ACCESS_ALL_FILES, + SITE_FILE_ACCESS_SITE_DIRECTORY, +} from '@studio/common/lib/site-file-access'; +import { RecommendedPHPVersion, SupportedPHPVersions } from '@studio/common/types/php-versions'; +import { __ } from '@wordpress/i18n'; +import { isUrl } from 'cli/lib/static-site-import/source'; +import { untildify } from 'cli/lib/utils'; +import { ValidationError } from 'cli/lib/validation-error'; +import { + CLI_AUTO_UPDATE_WP_VERSION, + coerceWpVersionOption, + getWpVersionOptionDescription, +} from 'cli/lib/wp-version-option'; +import { StudioArgv } from 'cli/types'; +import type { liberateWebsite } from 'cli/lib/data-liberation-client'; + +function coerceSiteId( value: string ) { + // Validate UUID format (8-4-4-4-12 hex characters) + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if ( ! uuidRegex.test( value ) ) { + throw new ValidationError( 'id', value, __( 'Must be a valid UUID' ) ); + } + return value; +} + +export const builder = ( yargs: StudioArgv ) => { + return yargs + .option( 'id', { + type: 'string', + describe: __( 'Site ID (UUID format, used internally by Studio app)' ), + hidden: true, + coerce: coerceSiteId, + } ) + .option( 'name', { + type: 'string', + describe: __( 'Site name' ), + } ) + .option( 'wp', { + type: 'string', + describe: getWpVersionOptionDescription(), + defaultDescription: CLI_AUTO_UPDATE_WP_VERSION, + coerce: coerceWpVersionOption, + } ) + .option( 'php', { + type: 'string', + describe: __( 'PHP version' ), + choices: SupportedPHPVersions, + defaultDescription: RecommendedPHPVersion, + } ) + .option( 'file-access', { + type: 'string', + describe: __( 'Which files PHP can access: the site directory only, or all files' ), + choices: [ SITE_FILE_ACCESS_SITE_DIRECTORY, SITE_FILE_ACCESS_ALL_FILES ] as const, + default: SITE_FILE_ACCESS_SITE_DIRECTORY, + } ) + .option( 'domain', { + type: 'string', + describe: __( 'Custom domain (e.g., "mysite.local")' ), + } ) + .option( 'https', { + type: 'boolean', + describe: __( 'Enable HTTPS for custom domain' ), + implies: 'domain', + } ) + .option( 'blueprint', { + type: 'string', + describe: __( 'Path or URL to Blueprint JSON file' ), + } ) + .option( 'from', { + type: 'string', + describe: __( 'Create the site from a static import source' ), + conflicts: 'blueprint', + coerce: ( value ) => { + if ( isUrl( value ) ) { + return value; + } + + return path.resolve( untildify( value ) ); + }, + } ) + .option( 'keep-source', { + type: 'boolean', + describe: __( + 'Keep the Data Liberation source capture at the sibling -source directory when importing from a URL' + ), + implies: 'from', + } ) + .option( 'static-site-importer-url', { + type: 'string', + describe: __( 'Static Site Importer plugin zip URL for --from imports' ), + defaultDescription: __( 'the newest Static Site Importer release' ), + conflicts: 'static-site-importer-path', + } ) + .option( 'static-site-importer-path', { + type: 'string', + describe: __( + 'Local Static Site Importer plugin zip for --from imports, including a paired build from the importer’s build:dev-package' + ), + conflicts: 'static-site-importer-url', + coerce: ( value ) => { + const pluginPath = path.resolve( untildify( value ) ); + if ( path.extname( pluginPath ).toLowerCase() !== '.zip' ) { + throw new ValidationError( + 'static-site-importer-path', + value, + __( 'Must be a .zip file' ) + ); + } + try { + if ( ! fs.statSync( pluginPath ).isFile() ) { + throw new Error(); + } + } catch { + throw new ValidationError( + 'static-site-importer-path', + value, + __( 'Must be an existing regular .zip file' ) + ); + } + return pluginPath; + }, + } ) + .option( 'original-blueprint-path', { + type: 'string', + hidden: true, + } ) + .option( 'admin-username', { + type: 'string', + describe: __( 'Admin username (defaults to "admin")' ), + } ) + .option( 'admin-password', { + type: 'string', + describe: __( + 'Admin password (auto-generated if not provided). Note: passwords in CLI arguments may be visible in process lists; consider using a Blueprint file for sensitive passwords.' + ), + } ) + .option( 'admin-email', { + type: 'string', + describe: __( 'Admin email (defaults to "admin@localhost.com")' ), + } ) + .option( 'start', { + type: 'boolean', + describe: __( 'Start the site after creation' ), + default: true, + } ) + .option( 'skip-browser', { + type: 'boolean', + describe: __( 'Skip opening the site in browser after starting' ), + default: false, + } ) + .option( 'skip-log-details', { + type: 'boolean', + describe: __( 'Skip printing site URL and admin credentials after creating' ), + default: false, + } ) + .option( 'flow-type', { + // Internal telemetry hint for the `studio_site_created` Tracks event, set by the + // desktop app when it spawns the CLI. Hidden from `--help`. + type: 'string', + hidden: true, + } ) + .check( ( argv ) => { + if ( argv.keepSource && ( ! argv.from || ! isUrl( argv.from ) ) ) { + throw new Error( __( '--keep-source requires --from with an HTTP(S) URL' ) ); + } + return true; + } ); +}; + +export const registerCommand = ( + yargs: StudioArgv, + dependencies: { liberate?: typeof liberateWebsite } = {} +) => { + return yargs.command( { + command: 'create', + describe: __( 'Create a new site' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/site/create' ); + return commandHandler( argv, dependencies ); + }, + } ); +}; diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index efff860ac9..26402ce743 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -34,11 +34,7 @@ import { removeDbConstants, } from '@studio/common/lib/remove-default-db-constants'; import { readSharedConfig } from '@studio/common/lib/shared-config'; -import { - SITE_FILE_ACCESS_ALL_FILES, - SITE_FILE_ACCESS_SITE_DIRECTORY, - type SiteFileAccess, -} from '@studio/common/lib/site-file-access'; +import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; import { sortSites } from '@studio/common/lib/sort-sites'; import { getServerFilesPath } from '@studio/common/lib/well-known-paths'; import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions'; @@ -89,16 +85,12 @@ import { import { getTracksOrigin, recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/tracks'; 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 {} from 'cli/lib/wp-version-option'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; import type { BlueprintV1Declaration } from '@wp-playground/blueprints'; +import type { builder } from 'cli/commands/site/create.command'; +import type { CommandArgs } from 'cli/types'; const defaultLogger = new Logger< LoggerAction >(); export type CreateCommandOptions = { @@ -539,427 +531,263 @@ function readBlueprint( blueprintPath: string ) { } } -function coerceSiteId( value: string ) { - // Validate UUID format (8-4-4-4-12 hex characters) - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if ( ! uuidRegex.test( value ) ) { - throw new ValidationError( 'id', value, __( 'Must be a valid UUID' ) ); +export async function commandHandler( + argv: CommandArgs< typeof builder >, + dependencies: { liberate?: typeof liberateWebsite } = {} +) { + const sourceUrl = argv.from && isUrl( argv.from ) ? argv.from : undefined; + let siteName = argv.from ? sourceSiteName( argv.from, argv.name ) : argv.name; + let sitePath = argv.path; + let wpVersion = argv.wp; + let phpVersion = argv.php; + let customDomain = argv.domain; + let enableHttps = !! argv.https; + let adminUsername = argv.adminUsername; + let adminPassword = argv.adminPassword; + let adminEmail = argv.adminEmail; + const fileAccess = argv.fileAccess; + + // Validate and resolve the WordPress version against available versions before prompting + if ( wpVersion && wpVersion !== 'latest' && wpVersion !== 'nightly' ) { + try { + defaultLogger.reportStart( LoggerAction.VALIDATE, __( 'Checking WordPress version…' ) ); + const availableVersions = await fetchWordPressVersions(); + const matchedVersion = availableVersions.find( + ( v ) => v.value === wpVersion || v.value.startsWith( wpVersion + '.' ) + ); + if ( ! matchedVersion ) { + const versionLabels = availableVersions + .filter( ( v ) => v.value !== 'latest' ) + .map( ( v ) => v.label ); + defaultLogger.reportError( + new LoggerError( + sprintf( + /* translators: %1$s: requested version, %2$s: list of available versions */ + __( 'WordPress version "%1$s" is not available. Available versions: %2$s' ), + wpVersion, + versionLabels.join( ', ' ) + ) + ) + ); + return; + } + // Resolve short versions to full versions (e.g. "6.7" → "6.7.2") + if ( matchedVersion.value !== wpVersion ) { + defaultLogger.reportSuccess( + sprintf( + /* translators: %1$s: requested version, %2$s: resolved version */ + __( 'WordPress version: %1$s → %2$s' ), + wpVersion, + matchedVersion.value + ) + ); + } else { + defaultLogger.reportSuccess( + sprintf( + /* translators: %s: WordPress version */ + __( 'WordPress version: %s' ), + wpVersion + ) + ); + } + wpVersion = matchedVersion.value; + } catch { + // If we can't fetch versions (network issue), let it proceed and fail later + } } - return value; -} -export const registerCommand = ( - yargs: StudioArgv, - dependencies: { liberate?: typeof liberateWebsite } = {} -) => { - return yargs.command( { - command: 'create', - describe: __( 'Create a new site' ), - builder: ( yargs ) => { - return yargs - .option( 'id', { - type: 'string', - describe: __( 'Site ID (UUID format, used internally by Studio app)' ), - hidden: true, - coerce: coerceSiteId, - } ) - .option( 'name', { - type: 'string', - describe: __( 'Site name' ), - } ) - .option( 'wp', { - type: 'string', - describe: getWpVersionOptionDescription(), - defaultDescription: CLI_AUTO_UPDATE_WP_VERSION, - coerce: coerceWpVersionOption, - } ) - .option( 'php', { - type: 'string', - describe: __( 'PHP version' ), - choices: SupportedPHPVersions, - defaultDescription: RecommendedPHPVersion, - } ) - .option( 'file-access', { - type: 'string', - describe: __( 'Which files PHP can access: the site directory only, or all files' ), - choices: [ SITE_FILE_ACCESS_SITE_DIRECTORY, SITE_FILE_ACCESS_ALL_FILES ] as const, - default: SITE_FILE_ACCESS_SITE_DIRECTORY, - } ) - .option( 'domain', { - type: 'string', - describe: __( 'Custom domain (e.g., "mysite.local")' ), - } ) - .option( 'https', { - type: 'boolean', - describe: __( 'Enable HTTPS for custom domain' ), - implies: 'domain', - } ) - .option( 'blueprint', { - type: 'string', - describe: __( 'Path or URL to Blueprint JSON file' ), - } ) - .option( 'from', { - type: 'string', - describe: __( 'Create the site from a static import source' ), - conflicts: 'blueprint', - coerce: ( value ) => { - if ( isUrl( value ) ) { - return value; - } + try { + if ( process.stdin.isTTY ) { + if ( ! siteName && ! sourceUrl ) { + const defaultName = await generateSiteName(); + siteName = await input( { + message: __( 'Site name:' ), + default: defaultName, + } ); + } - return path.resolve( untildify( value ) ); - }, - } ) - .option( 'keep-source', { - type: 'boolean', - describe: __( - 'Keep the Data Liberation source capture at the sibling -source directory when importing from a URL' - ), - implies: 'from', - } ) - .option( 'static-site-importer-url', { - type: 'string', - describe: __( 'Static Site Importer plugin zip URL for --from imports' ), - defaultDescription: __( 'the newest Static Site Importer release' ), - conflicts: 'static-site-importer-path', - } ) - .option( 'static-site-importer-path', { - type: 'string', - describe: __( - 'Local Static Site Importer plugin zip for --from imports, including a paired build from the importer’s build:dev-package' - ), - conflicts: 'static-site-importer-url', - coerce: ( value ) => { - const pluginPath = path.resolve( untildify( value ) ); - if ( path.extname( pluginPath ).toLowerCase() !== '.zip' ) { - throw new ValidationError( - 'static-site-importer-path', - value, - __( 'Must be a .zip file' ) - ); - } - try { - if ( ! fs.statSync( pluginPath ).isFile() ) { - throw new Error(); - } - } catch { - throw new ValidationError( - 'static-site-importer-path', - value, - __( 'Must be an existing regular .zip file' ) - ); - } - return pluginPath; - }, - } ) - .option( 'original-blueprint-path', { - type: 'string', - hidden: true, - } ) - .option( 'admin-username', { - type: 'string', - describe: __( 'Admin username (defaults to "admin")' ), - } ) - .option( 'admin-password', { - type: 'string', - describe: __( - 'Admin password (auto-generated if not provided). Note: passwords in CLI arguments may be visible in process lists; consider using a Blueprint file for sensitive passwords.' - ), - } ) - .option( 'admin-email', { - type: 'string', - describe: __( 'Admin email (defaults to "admin@localhost.com")' ), - } ) - .option( 'start', { - type: 'boolean', - describe: __( 'Start the site after creation' ), - default: true, - } ) - .option( 'skip-browser', { - type: 'boolean', - describe: __( 'Skip opening the site in browser after starting' ), - default: false, - } ) - .option( 'skip-log-details', { - type: 'boolean', - describe: __( 'Skip printing site URL and admin credentials after creating' ), - default: false, - } ) - .option( 'flow-type', { - // Internal telemetry hint for the `studio_site_created` Tracks event, set by the - // desktop app when it spawns the CLI. Hidden from `--help`. - type: 'string', - hidden: true, - } ) - .check( ( argv ) => { - if ( argv.keepSource && ( ! argv.from || ! isUrl( argv.from ) ) ) { - throw new Error( __( '--keep-source requires --from with an HTTP(S) URL' ) ); - } - return true; + const pathWasExplicitlyProvided = sitePath !== process.cwd(); + if ( ! pathWasExplicitlyProvided ) { + const suggestedPath = getDefaultSitePath( siteName || __( 'My WordPress Website' ) ); + const promptedPath = await input( { + message: __( 'Site path:' ), + default: suggestedPath, } ); - }, - handler: async ( argv ) => { - const sourceUrl = argv.from && isUrl( argv.from ) ? argv.from : undefined; - let siteName = argv.from ? sourceSiteName( argv.from, argv.name ) : argv.name; - let sitePath = argv.path; - let wpVersion = argv.wp; - let phpVersion = argv.php; - let customDomain = argv.domain; - let enableHttps = !! argv.https; - let adminUsername = argv.adminUsername; - let adminPassword = argv.adminPassword; - let adminEmail = argv.adminEmail; - const fileAccess = argv.fileAccess; - - // Validate and resolve the WordPress version against available versions before prompting - if ( wpVersion && wpVersion !== 'latest' && wpVersion !== 'nightly' ) { + sitePath = path.resolve( untildify( promptedPath ) ); + } + + if ( ! wpVersion ) { + let wpChoices: { name: string; value: string }[]; try { - defaultLogger.reportStart( LoggerAction.VALIDATE, __( 'Checking WordPress version…' ) ); - const availableVersions = await fetchWordPressVersions(); - const matchedVersion = availableVersions.find( - ( v ) => v.value === wpVersion || v.value.startsWith( wpVersion + '.' ) - ); - if ( ! matchedVersion ) { - const versionLabels = availableVersions - .filter( ( v ) => v.value !== 'latest' ) - .map( ( v ) => v.label ); - defaultLogger.reportError( - new LoggerError( - sprintf( - /* translators: %1$s: requested version, %2$s: list of available versions */ - __( 'WordPress version "%1$s" is not available. Available versions: %2$s' ), - wpVersion, - versionLabels.join( ', ' ) - ) - ) - ); - return; - } - // Resolve short versions to full versions (e.g. "6.7" → "6.7.2") - if ( matchedVersion.value !== wpVersion ) { - defaultLogger.reportSuccess( - sprintf( - /* translators: %1$s: requested version, %2$s: resolved version */ - __( 'WordPress version: %1$s → %2$s' ), - wpVersion, - matchedVersion.value - ) - ); - } else { - defaultLogger.reportSuccess( - sprintf( - /* translators: %s: WordPress version */ - __( 'WordPress version: %s' ), - wpVersion - ) - ); - } - wpVersion = matchedVersion.value; + const versions = await fetchWordPressVersions(); + wpChoices = versions.map( ( v ) => ( { + name: + v.value === DEFAULT_WORDPRESS_VERSION + ? sprintf( __( 'Auto-update (%s)' ), v.label ) + : v.label, + value: v.value, + } ) ); } catch { - // If we can't fetch versions (network issue), let it proceed and fail later + // Offline or API failure — offer only the auto-update mode + wpChoices = [ + { + name: __( 'Auto-update (recommended)' ), + value: DEFAULT_WORDPRESS_VERSION, + }, + ]; } + wpVersion = await select( { + message: __( 'WordPress version:' ), + choices: wpChoices, + default: DEFAULT_WORDPRESS_VERSION, + loop: false, + } ); } - try { - if ( process.stdin.isTTY ) { - if ( ! siteName && ! sourceUrl ) { - const defaultName = await generateSiteName(); - siteName = await input( { - message: __( 'Site name:' ), - default: defaultName, - } ); - } - - const pathWasExplicitlyProvided = sitePath !== process.cwd(); - if ( ! pathWasExplicitlyProvided ) { - const suggestedPath = getDefaultSitePath( siteName || __( 'My WordPress Website' ) ); - const promptedPath = await input( { - message: __( 'Site path:' ), - default: suggestedPath, - } ); - sitePath = path.resolve( untildify( promptedPath ) ); - } - - if ( ! wpVersion ) { - let wpChoices: { name: string; value: string }[]; - try { - const versions = await fetchWordPressVersions(); - wpChoices = versions.map( ( v ) => ( { - name: - v.value === DEFAULT_WORDPRESS_VERSION - ? sprintf( __( 'Auto-update (%s)' ), v.label ) - : v.label, - value: v.value, - } ) ); - } catch { - // Offline or API failure — offer only the auto-update mode - wpChoices = [ - { - name: __( 'Auto-update (recommended)' ), - value: DEFAULT_WORDPRESS_VERSION, - }, - ]; - } - wpVersion = await select( { - message: __( 'WordPress version:' ), - choices: wpChoices, - default: DEFAULT_WORDPRESS_VERSION, - loop: false, - } ); - } - - if ( ! phpVersion ) { - phpVersion = await select( { - message: __( 'PHP version:' ), - choices: SupportedPHPVersions.map( ( v ) => ( { - name: v === RecommendedPHPVersion ? sprintf( __( '%s (recommended)' ), v ) : v, - value: v, - } ) ), - default: RecommendedPHPVersion, - } ); - } + if ( ! phpVersion ) { + phpVersion = await select( { + message: __( 'PHP version:' ), + choices: SupportedPHPVersions.map( ( v ) => ( { + name: v === RecommendedPHPVersion ? sprintf( __( '%s (recommended)' ), v ) : v, + value: v, + } ) ), + default: RecommendedPHPVersion, + } ); + } - if ( ! customDomain ) { - const cliConfig = await readCliConfig(); - const existingDomains = cliConfig.sites - .map( ( site ) => site.customDomain ) - .filter( ( domain ): domain is string => Boolean( domain ) ); - - customDomain = await input( { - message: __( 'Custom domain (leave empty to skip):' ), - validate: ( value ) => - getDomainNameValidationError( !! value, value, existingDomains ) || true, - } ); - } + if ( ! customDomain ) { + const cliConfig = await readCliConfig(); + const existingDomains = cliConfig.sites + .map( ( site ) => site.customDomain ) + .filter( ( domain ): domain is string => Boolean( domain ) ); - if ( customDomain && ! argv.https ) { - enableHttps = await confirm( { - message: __( 'Enable HTTPS?' ), - default: false, - } ); - } + customDomain = await input( { + message: __( 'Custom domain (leave empty to skip):' ), + validate: ( value ) => + getDomainNameValidationError( !! value, value, existingDomains ) || true, + } ); + } - if ( ! adminUsername ) { - adminUsername = await input( { - message: __( 'Admin username:' ), - default: 'admin', - validate: ( value ) => validateAdminUsername( value ) || true, - } ); - } + if ( customDomain && ! argv.https ) { + enableHttps = await confirm( { + message: __( 'Enable HTTPS?' ), + default: false, + } ); + } - if ( ! adminPassword ) { - adminPassword = await password( { - message: __( 'Admin password (leave empty to auto-generate):' ), - mask: true, - } ); - if ( ! adminPassword ) { - adminPassword = undefined; - } - } + if ( ! adminUsername ) { + adminUsername = await input( { + message: __( 'Admin username:' ), + default: 'admin', + validate: ( value ) => validateAdminUsername( value ) || true, + } ); + } - if ( ! adminEmail ) { - adminEmail = await input( { - message: __( 'Admin email:' ), - default: 'admin@localhost.com', - validate: ( value ) => validateAdminEmail( value ) || true, - } ); - } + if ( ! adminPassword ) { + adminPassword = await password( { + message: __( 'Admin password (leave empty to auto-generate):' ), + mask: true, + } ); + if ( ! adminPassword ) { + adminPassword = undefined; } - } catch { - // User cancelled the prompt (Ctrl+C) - return; } - // Apply defaults for non-interactive mode when flags weren't provided - wpVersion = wpVersion ?? DEFAULT_WORDPRESS_VERSION; - - const config: CreateCommandOptions = { - name: siteName, - siteId: argv.id, - wpVersion, - phpVersion: phpVersion ?? RecommendedPHPVersion, - fileAccess, - customDomain, - enableHttps, - adminUsername, - adminPassword, - adminEmail, - noStart: ! argv.start, - skipBrowser: !! argv.skipBrowser, - skipLogDetails: !! argv.skipLogDetails, - flowType: parseFlowType( argv.flowType ), - }; + if ( ! adminEmail ) { + adminEmail = await input( { + message: __( 'Admin email:' ), + default: 'admin@localhost.com', + validate: ( value ) => validateAdminEmail( value ) || true, + } ); + } + } + } catch { + // User cancelled the prompt (Ctrl+C) + return; + } - try { - let preparedImport: PreparedSourceImport | undefined; - if ( argv.from ) { - preparedImport = await prepareSourceImport( - argv.from, - sitePath, - siteName, - defaultLogger, - { - staticSiteImporter: argv.staticSiteImporterPath - ? { path: argv.staticSiteImporterPath } - : argv.staticSiteImporterUrl, - liberate: dependencies.liberate, - } - ); - config.blueprint = preparedImport.blueprint; - config.name = preparedImport.siteName; - } else if ( argv.blueprint ) { - if ( isUrl( argv.blueprint ) ) { - config.blueprint = { - uri: argv.blueprint, - contents: await fetchBlueprint( argv.blueprint ), - }; - } else { - const uri = path.resolve( untildify( argv.blueprint ) ); - - config.blueprint = { - uri, - contents: readBlueprint( uri ), - }; - - // When invoked by the desktop app, the blueprint contents come from a temp file - // but resources should be resolved relative to the original file location. - // For gallery blueprints the path is a URL; use it directly. - if ( argv.originalBlueprintPath ) { - const originalPath = argv.originalBlueprintPath; - config.blueprint.uri = isUrl( originalPath ) - ? originalPath - : path.resolve( originalPath ); - } - } - } + // Apply defaults for non-interactive mode when flags weren't provided + wpVersion = wpVersion ?? DEFAULT_WORDPRESS_VERSION; + + const config: CreateCommandOptions = { + name: siteName, + siteId: argv.id, + wpVersion, + phpVersion: phpVersion ?? RecommendedPHPVersion, + fileAccess, + customDomain, + enableHttps, + adminUsername, + adminPassword, + adminEmail, + noStart: ! argv.start, + skipBrowser: !! argv.skipBrowser, + skipLogDetails: !! argv.skipLogDetails, + flowType: parseFlowType( argv.flowType ), + }; - try { - await runCommand( sitePath, config ); - if ( preparedImport ) { - await completeSourceImport( preparedImport, sitePath, { - keepSource: !! argv.keepSource, - } ); - } - } finally { - await removeStaticSiteImportBundle( config.blueprint?.staticSiteImport ); - } + try { + let preparedImport: PreparedSourceImport | undefined; + if ( argv.from ) { + preparedImport = await prepareSourceImport( argv.from, sitePath, siteName, defaultLogger, { + staticSiteImporter: argv.staticSiteImporterPath + ? { path: argv.staticSiteImporterPath } + : argv.staticSiteImporterUrl, + liberate: dependencies.liberate, + } ); + config.blueprint = preparedImport.blueprint; + config.name = preparedImport.siteName; + } else if ( argv.blueprint ) { + if ( isUrl( argv.blueprint ) ) { + config.blueprint = { + uri: argv.blueprint, + contents: await fetchBlueprint( argv.blueprint ), + }; + } else { + const uri = path.resolve( untildify( argv.blueprint ) ); - if ( __ENABLE_CLI_TELEMETRY__ && ! argv.avoidTelemetry ) { - bumpStat( - __IS_PACKAGED_FOR_NPM__ - ? StatsGroup.STUDIO_CLI_SITE_CREATE_NPM - : StatsGroup.STUDIO_CLI_SITE_CREATE_APP, - getPlatformMetric() - ); - } - } catch ( error ) { - if ( error instanceof LoggerError ) { - defaultLogger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to create site' ), error ); - defaultLogger.reportError( loggerError ); + config.blueprint = { + uri, + contents: readBlueprint( uri ), + }; + + // When invoked by the desktop app, the blueprint contents come from a temp file + // but resources should be resolved relative to the original file location. + // For gallery blueprints the path is a URL; use it directly. + if ( argv.originalBlueprintPath ) { + const originalPath = argv.originalBlueprintPath; + config.blueprint.uri = isUrl( originalPath ) + ? originalPath + : path.resolve( originalPath ); } } - }, - } ); -}; + } + + try { + await runCommand( sitePath, config ); + if ( preparedImport ) { + await completeSourceImport( preparedImport, sitePath, { + keepSource: !! argv.keepSource, + } ); + } + } finally { + await removeStaticSiteImportBundle( config.blueprint?.staticSiteImport ); + } + + if ( __ENABLE_CLI_TELEMETRY__ && ! argv.avoidTelemetry ) { + bumpStat( + __IS_PACKAGED_FOR_NPM__ + ? StatsGroup.STUDIO_CLI_SITE_CREATE_NPM + : StatsGroup.STUDIO_CLI_SITE_CREATE_APP, + getPlatformMetric() + ); + } + } catch ( error ) { + if ( error instanceof LoggerError ) { + defaultLogger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to create site' ), error ); + defaultLogger.reportError( loggerError ); + } + } +} diff --git a/apps/cli/commands/site/delete.command.ts b/apps/cli/commands/site/delete.command.ts new file mode 100644 index 0000000000..31c212ff8c --- /dev/null +++ b/apps/cli/commands/site/delete.command.ts @@ -0,0 +1,40 @@ +import { __ } from '@wordpress/i18n'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( yargs: StudioArgv ) => { + return yargs + .positional( 'sites', { + type: 'string', + array: true, + description: __( 'Site paths or IDs to delete' ), + } ) + .option( 'files', { + type: 'boolean', + description: __( 'Move site files to trash (use --no-files to keep files)' ), + default: true, + } ) + .option( 'dry-run', { + alias: 'preview', + type: 'boolean', + description: __( 'Show which sites and files would be deleted without deleting them' ), + default: false, + } ) + .option( 'format', { + type: 'string', + choices: [ 'text', 'json' ] as const, + default: 'text' as const, + description: __( 'Output format' ), + } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'delete [sites..]', + describe: __( 'Delete site' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/site/delete' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/site/delete.ts b/apps/cli/commands/site/delete.ts index 6fa822b703..797d28cb92 100644 --- a/apps/cli/commands/site/delete.ts +++ b/apps/cli/commands/site/delete.ts @@ -28,7 +28,8 @@ import { getTracksOrigin, recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/track import { untildify } from 'cli/lib/utils'; import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; +import type { builder } from 'cli/commands/site/delete.command'; +import type { CommandArgs } from 'cli/types'; const defaultLogger = new Logger< LoggerAction >(); @@ -515,52 +516,20 @@ function collectIdentities( argv: { sites?: string | string[]; path: string } ): return [ argv.path ]; } - -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'delete [sites..]', - describe: __( 'Delete site' ), - builder: ( yargs ) => { - return yargs - .positional( 'sites', { - type: 'string', - array: true, - description: __( 'Site paths or IDs to delete' ), - } ) - .option( 'files', { - type: 'boolean', - description: __( 'Move site files to trash (use --no-files to keep files)' ), - default: true, - } ) - .option( 'dry-run', { - alias: 'preview', - type: 'boolean', - description: __( 'Show which sites and files would be deleted without deleting them' ), - default: false, - } ) - .option( 'format', { - type: 'string', - choices: [ 'text', 'json' ] as const, - default: 'text' as const, - description: __( 'Output format' ), - } ); - }, - handler: async ( argv ) => { - try { - await runDeleteCommand( { - identities: collectIdentities( argv ), - deleteFiles: argv.files, - dryRun: argv.dryRun, - format: argv.format, - } ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - defaultLogger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to delete site' ), error ); - defaultLogger.reportError( loggerError ); - } - } - }, - } ); -}; +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + try { + await runDeleteCommand( { + identities: collectIdentities( argv ), + deleteFiles: argv.files, + dryRun: argv.dryRun, + format: argv.format, + } ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + defaultLogger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to delete site' ), error ); + defaultLogger.reportError( loggerError ); + } + } +} diff --git a/apps/cli/commands/site/list.command.ts b/apps/cli/commands/site/list.command.ts new file mode 100644 index 0000000000..5e7d3f7ce2 --- /dev/null +++ b/apps/cli/commands/site/list.command.ts @@ -0,0 +1,28 @@ +import { __ } from '@wordpress/i18n'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( yargs: StudioArgv ) => { + return yargs + .option( 'format', { + type: 'string', + choices: [ 'table', 'json' ] as const, + default: 'table' as const, + // translators: Refers to the output format of the `studio site list` CLI command ("table" or "json") + description: __( 'Output format' ), + } ) + .option( 'path', { + hidden: true, + } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'list', + describe: __( 'List sites' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/site/list' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/site/list.ts b/apps/cli/commands/site/list.ts index 1ace0c9d0c..2ce64bf60d 100644 --- a/apps/cli/commands/site/list.ts +++ b/apps/cli/commands/site/list.ts @@ -11,7 +11,8 @@ import { pickPublicSiteFields } from 'cli/lib/site-public-fields'; import { isSiteRunning } from 'cli/lib/site-utils'; import { getColumnWidths, getPrettyPath } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; +import type { builder } from 'cli/commands/site/list.command'; +import type { CommandArgs } from 'cli/types'; interface SiteListTableEntry { id: string; @@ -127,35 +128,15 @@ export async function runCommand( format: 'table' | 'json' ): Promise< void > { await disconnectFromDaemon(); } } - -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'list', - describe: __( 'List sites' ), - builder: ( yargs ) => { - return yargs - .option( 'format', { - type: 'string', - choices: [ 'table', 'json' ] as const, - default: 'table' as const, - // translators: Refers to the output format of the `studio site list` CLI command ("table" or "json") - description: __( 'Output format' ), - } ) - .option( 'path', { - hidden: true, - } ); - }, - handler: async ( argv ) => { - try { - await runCommand( argv.format ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to list sites' ), error ); - logger.reportError( loggerError ); - } - } - }, - } ); -}; +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + try { + await runCommand( argv.format ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to list sites' ), error ); + logger.reportError( loggerError ); + } + } +} diff --git a/apps/cli/commands/site/start.command.ts b/apps/cli/commands/site/start.command.ts new file mode 100644 index 0000000000..9adf3472a5 --- /dev/null +++ b/apps/cli/commands/site/start.command.ts @@ -0,0 +1,28 @@ +import { __ } from '@wordpress/i18n'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( yargs: StudioArgv ) => { + return yargs + .option( 'skip-browser', { + type: 'boolean', + describe: __( 'Skip opening the site in browser after starting' ), + default: false, + } ) + .option( 'skip-log-details', { + type: 'boolean', + describe: __( 'Skip printing site URL and admin credentials after starting' ), + default: false, + } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'start', + describe: __( 'Start site' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/site/start' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/site/start.ts b/apps/cli/commands/site/start.ts index 61935a8d35..7844dd95fb 100644 --- a/apps/cli/commands/site/start.ts +++ b/apps/cli/commands/site/start.ts @@ -10,7 +10,8 @@ import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/si import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; import { isServerRunning, startWordPressServer } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; +import type { builder } from 'cli/commands/site/start.command'; +import type { CommandArgs } from 'cli/types'; const defaultLogger = new Logger< LoggerAction >(); @@ -122,35 +123,15 @@ async function startSite( await disconnectFromDaemon(); } } - -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'start', - describe: __( 'Start site' ), - builder: ( yargs ) => { - return yargs - .option( 'skip-browser', { - type: 'boolean', - describe: __( 'Skip opening the site in browser after starting' ), - default: false, - } ) - .option( 'skip-log-details', { - type: 'boolean', - describe: __( 'Skip printing site URL and admin credentials after starting' ), - default: false, - } ); - }, - handler: async ( argv ) => { - try { - await runCommand( argv.path, argv.skipBrowser, argv.skipLogDetails ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - defaultLogger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to start site' ), error ); - defaultLogger.reportError( loggerError ); - } - } - }, - } ); -}; +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + try { + await runCommand( argv.path, argv.skipBrowser, argv.skipLogDetails ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + defaultLogger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to start site' ), error ); + defaultLogger.reportError( loggerError ); + } + } +} diff --git a/apps/cli/commands/site/status.command.ts b/apps/cli/commands/site/status.command.ts new file mode 100644 index 0000000000..26732438a6 --- /dev/null +++ b/apps/cli/commands/site/status.command.ts @@ -0,0 +1,23 @@ +import { __ } from '@wordpress/i18n'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( yargs: StudioArgv ) => { + return yargs.option( 'format', { + type: 'string', + choices: [ 'table', 'json' ] as const, + default: 'table' as const, + description: __( 'Output format' ), + } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'status', + describe: __( 'Get status of site' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/site/status' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/site/status.ts b/apps/cli/commands/site/status.ts index c244520d2d..9df9d3e274 100644 --- a/apps/cli/commands/site/status.ts +++ b/apps/cli/commands/site/status.ts @@ -10,7 +10,8 @@ import { getSiteEmailDirectory } from 'cli/lib/site-email'; import { getPrettyPath } from 'cli/lib/utils'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; +import type { builder } from 'cli/commands/site/status.command'; +import type { CommandArgs } from 'cli/types'; const logger = new Logger< LoggerAction >(); @@ -122,30 +123,15 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) await disconnectFromDaemon(); } } - -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'status', - describe: __( 'Get status of site' ), - builder: ( yargs ) => { - return yargs.option( 'format', { - type: 'string', - choices: [ 'table', 'json' ] as const, - default: 'table' as const, - description: __( 'Output format' ), - } ); - }, - handler: async ( argv ) => { - try { - await runCommand( argv.path, argv.format ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to load site status' ), error ); - logger.reportError( loggerError ); - } - } - }, - } ); -}; +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + try { + await runCommand( argv.path, argv.format ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to load site status' ), error ); + logger.reportError( loggerError ); + } + } +} diff --git a/apps/cli/commands/site/stop.command.ts b/apps/cli/commands/site/stop.command.ts new file mode 100644 index 0000000000..4e0bf08a3d --- /dev/null +++ b/apps/cli/commands/site/stop.command.ts @@ -0,0 +1,22 @@ +import { __ } from '@wordpress/i18n'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( yargs: StudioArgv ) => { + return yargs.option( 'all', { + type: 'boolean', + describe: __( 'Stop all sites' ), + default: false, + } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'stop', + describe: __( 'Stop site(s)' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/site/stop' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/site/stop.ts b/apps/cli/commands/site/stop.ts index edd456e6b1..5b6d31b674 100644 --- a/apps/cli/commands/site/stop.ts +++ b/apps/cli/commands/site/stop.ts @@ -22,7 +22,8 @@ import { stopWordPressServer, } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; +import type { builder } from 'cli/commands/site/stop.command'; +import type { CommandArgs } from 'cli/types'; const defaultLogger = new Logger< LoggerAction >(); @@ -151,36 +152,22 @@ async function stopSites( await disconnectFromDaemon(); } } - -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'stop', - describe: __( 'Stop site(s)' ), - builder: ( yargs ) => { - return yargs.option( 'all', { - type: 'boolean', - describe: __( 'Stop all sites' ), - default: false, - } ); - }, - handler: async ( argv ) => { - try { - if ( argv.all ) { - await runCommand( Mode.STOP_ALL_SITES, undefined ); - } else { - await runCommand( Mode.STOP_SINGLE_SITE, argv.path ); - } - } catch ( error ) { - if ( error instanceof LoggerError ) { - defaultLogger.reportError( error ); - } else { - const loggerError = new LoggerError( - argv.all ? __( 'Failed to stop sites' ) : __( 'Failed to stop site' ), - error - ); - defaultLogger.reportError( loggerError ); - } - } - }, - } ); -}; +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + try { + if ( argv.all ) { + await runCommand( Mode.STOP_ALL_SITES, undefined ); + } else { + await runCommand( Mode.STOP_SINGLE_SITE, argv.path ); + } + } catch ( error ) { + if ( error instanceof LoggerError ) { + defaultLogger.reportError( error ); + } else { + const loggerError = new LoggerError( + argv.all ? __( 'Failed to stop sites' ) : __( 'Failed to stop site' ), + error + ); + defaultLogger.reportError( loggerError ); + } + } +} diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index 876b6c6608..db16755257 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -47,7 +47,8 @@ import { recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/tracks'; import { ProcessDescription } from 'cli/lib/types/process-manager-ipc'; import { runBlueprint, startWordPressServer } from 'cli/lib/wordpress-server-manager'; import { Logger } from 'cli/logger'; -import { registerCommand, runCommand } from '../create'; +import { runCommand } from '../create'; +import { registerCommand } from '../create.command'; vi.mock( '@inquirer/prompts' ); vi.mock( '@studio/common/lib/fs-utils' ); diff --git a/apps/cli/commands/tests/export.test.ts b/apps/cli/commands/tests/export.test.ts index f68b6a46ad..5796c357fc 100644 --- a/apps/cli/commands/tests/export.test.ts +++ b/apps/cli/commands/tests/export.test.ts @@ -10,7 +10,8 @@ import { withSiteOperation } from 'cli/lib/site-operations'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; import { recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/tracks'; import { Logger, LoggerError } from 'cli/logger'; -import { registerCommand, runCommand } from '../export'; +import { runCommand } from '../export'; +import { registerCommand } from '../export.command'; import type { SiteData } from 'cli/lib/cli-config/core'; vi.mock( '@studio/common/lib/deploy-ignore', () => ( { diff --git a/apps/cli/commands/ui.command.ts b/apps/cli/commands/ui.command.ts new file mode 100644 index 0000000000..2aac563a2e --- /dev/null +++ b/apps/cli/commands/ui.command.ts @@ -0,0 +1,27 @@ +import { __ } from '@wordpress/i18n'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( uiYargs: StudioArgv ) => + uiYargs + .option( 'port', { + type: 'number', + description: __( 'Port to listen on' ), + } ) + .option( 'open', { + type: 'boolean', + default: true, + description: __( 'Open the UI in your default browser' ), + } ) + .option( 'path', { hidden: true } ); + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'ui', + describe: __( 'Start the local Studio web UI and open it in your browser' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/ui' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/ui.ts b/apps/cli/commands/ui.ts index d077aadf1d..d3cc7cd935 100644 --- a/apps/cli/commands/ui.ts +++ b/apps/cli/commands/ui.ts @@ -4,87 +4,69 @@ import { __ } from '@wordpress/i18n'; import { openBrowser } from 'cli/lib/browser'; import { capturePreview } from 'cli/lib/capture-preview'; import { getTracksOrigin, recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/tracks'; -import { StudioArgv } from 'cli/types'; +import type { builder } from 'cli/commands/ui.command'; +import type { CommandArgs } from 'cli/types'; +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + // `@studio/local` is bundled into the CLI from source (see the vite + // alias); the dynamic import keeps Express off the startup path of + // every other command. + const [ { startLocalServer }, { getSessionsDirectory }, { STUDIO_SITES_ROOT } ] = + await Promise.all( [ + import( '@studio/local' ), + import( '@studio/common/lib/well-known-paths' ), + import( 'cli/lib/site-paths' ), + ] ); -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'ui', - describe: __( 'Start the local Studio web UI and open it in your browser' ), - builder: ( uiYargs: StudioArgv ) => - uiYargs - .option( 'port', { - type: 'number', - description: __( 'Port to listen on' ), - } ) - .option( 'open', { - type: 'boolean', - default: true, - description: __( 'Open the UI in your default browser' ), - } ) - .option( 'path', { hidden: true } ), - handler: async ( argv ) => { - // `@studio/local` is bundled into the CLI from source (see the vite - // alias); the dynamic import keeps Express off the startup path of - // every other command. - const [ { startLocalServer }, { getSessionsDirectory }, { STUDIO_SITES_ROOT } ] = - await Promise.all( [ - import( '@studio/local' ), - import( '@studio/common/lib/well-known-paths' ), - import( 'cli/lib/site-paths' ), - ] ); + // The server forks this same CLI for site + agent operations. When run + // from the packaged CLI, `process.argv[1]` is that binary; + // `STUDIO_CLI_BIN` overrides it for development. + const cliBinary = process.env.STUDIO_CLI_BIN ?? process.argv[ 1 ]; - // The server forks this same CLI for site + agent operations. When run - // from the packaged CLI, `process.argv[1]` is that binary; - // `STUDIO_CLI_BIN` overrides it for development. - const cliBinary = process.env.STUDIO_CLI_BIN ?? process.argv[ 1 ]; + // Everything forked from here inherits this, so browser usage isn't counted as + // bare CLI. `v2` because the browser serves `apps/ui`. + process.env.STUDIO_TRACKS_ORIGIN = 'studio-web:v2'; - // Everything forked from here inherits this, so browser usage isn't counted as - // bare CLI. `v2` because the browser serves `apps/ui`. - process.env.STUDIO_TRACKS_ORIGIN = 'studio-web:v2'; + // The built browser UI (apps/ui `dist-local`) is copied next to the CLI + // bundle at build time. In dev it may be absent — the server then + // serves the API only and the UI is run via + // `npm run dev:local --workspace=apps/ui`. + const uiDist = + process.env.STUDIO_LOCAL_UI_DIST ?? + path.join( path.dirname( fileURLToPath( import.meta.url ) ), 'ui' ); - // The built browser UI (apps/ui `dist-local`) is copied next to the CLI - // bundle at build time. In dev it may be absent — the server then - // serves the API only and the UI is run via - // `npm run dev:local --workspace=apps/ui`. - const uiDist = - process.env.STUDIO_LOCAL_UI_DIST ?? - path.join( path.dirname( fileURLToPath( import.meta.url ) ), 'ui' ); - - const server = await startLocalServer( { - cliBinary, - sessionsRoot: getSessionsDirectory(), - sitesRoot: STUDIO_SITES_ROOT, - port: argv.port as number | undefined, - uiDist, - // The CLI wrapper owns the opt-out and the common props. - recordTracksEvent: ( event, props ) => - recordTracksEvent( event, { ...getTracksOrigin(), ...props } ), - capturePreview, - } ); + const server = await startLocalServer( { + cliBinary, + sessionsRoot: getSessionsDirectory(), + sitesRoot: STUDIO_SITES_ROOT, + port: argv.port as number | undefined, + uiDist, + // The CLI wrapper owns the opt-out and the common props. + recordTracksEvent: ( event, props ) => + recordTracksEvent( event, { ...getTracksOrigin(), ...props } ), + capturePreview, + } ); - // The browser UI's equivalent of the desktop's `appBoot` launch event. No - // `is_first_launch`: the marker the desktop reads lives in its own `app.json`, - // and the install id is shared, so neither can tell a first browser launch - // from a first desktop one. - void recordTracksEvent( TRACKS_EVENTS.APP_LAUNCH, { ...getTracksOrigin() } ).catch( - () => undefined - ); + // The browser UI's equivalent of the desktop's `appBoot` launch event. No + // `is_first_launch`: the marker the desktop reads lives in its own `app.json`, + // and the install id is shared, so neither can tell a first browser launch + // from a first desktop one. + void recordTracksEvent( TRACKS_EVENTS.APP_LAUNCH, { ...getTracksOrigin() } ).catch( + () => undefined + ); - console.log( '' ); - console.log( __( 'WordPress Studio is running at:' ) ); - console.log( ` ${ server.url }` ); - console.log( '' ); - console.log( __( 'Press Ctrl+C to stop.' ) ); + console.log( '' ); + console.log( __( 'WordPress Studio is running at:' ) ); + console.log( ` ${ server.url }` ); + console.log( '' ); + console.log( __( 'Press Ctrl+C to stop.' ) ); - if ( argv.open ) { - await openBrowser( server.url ).catch( () => undefined ); - } + if ( argv.open ) { + await openBrowser( server.url ).catch( () => undefined ); + } - const shutdown = () => { - void server.close().finally( () => process.exit( 0 ) ); - }; - process.on( 'SIGINT', shutdown ); - process.on( 'SIGTERM', shutdown ); - }, - } ); -}; + const shutdown = () => { + void server.close().finally( () => process.exit( 0 ) ); + }; + process.on( 'SIGINT', shutdown ); + process.on( 'SIGTERM', shutdown ); +} diff --git a/apps/cli/commands/uninstall.command.ts b/apps/cli/commands/uninstall.command.ts new file mode 100644 index 0000000000..3e61d47f91 --- /dev/null +++ b/apps/cli/commands/uninstall.command.ts @@ -0,0 +1,23 @@ +import { __ } from '@wordpress/i18n'; +import { StudioArgv } from 'cli/types'; + +export const builder = ( yargs: StudioArgv ) => { + return yargs.option( 'purge', { + type: 'boolean', + alias: 'all', + describe: __( 'Also delete your Studio config (~/.studio)' ), + default: false, + } ); +}; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'uninstall', + describe: __( 'Uninstall the standalone Studio CLI' ), + builder, + handler: async ( argv ) => { + const { commandHandler } = await import( 'cli/commands/uninstall' ); + return commandHandler( argv ); + }, + } ); +}; diff --git a/apps/cli/commands/uninstall.ts b/apps/cli/commands/uninstall.ts index ba8152e329..9f95cc93f8 100644 --- a/apps/cli/commands/uninstall.ts +++ b/apps/cli/commands/uninstall.ts @@ -8,7 +8,8 @@ import trash from 'trash'; import { Mode, runCommand as stopSites } from 'cli/commands/site/stop'; import { confirm } from 'cli/lib/prompts'; import { getCliInstallKind } from 'cli/lib/update-notifier'; -import { StudioArgv } from 'cli/types'; +import type { builder } from 'cli/commands/uninstall.command'; +import type { CommandArgs } from 'cli/types'; // The launcher execs `/bin/node`, so the running binary's grandparent // is the standalone install root (handles a STUDIO_CLI_HOME override too — no need @@ -155,21 +156,6 @@ export async function runCommand( purge: boolean ): Promise< void > { ); } } - -export const registerCommand = ( yargs: StudioArgv ) => { - return yargs.command( { - command: 'uninstall', - describe: __( 'Uninstall the standalone Studio CLI' ), - builder: ( yargs ) => { - return yargs.option( 'purge', { - type: 'boolean', - alias: 'all', - describe: __( 'Also delete your Studio config (~/.studio)' ), - default: false, - } ); - }, - handler: async ( argv ) => { - await runCommand( Boolean( argv.purge ) ); - }, - } ); -}; +export async function commandHandler( argv: CommandArgs< typeof builder > ) { + await runCommand( Boolean( argv.purge ) ); +} diff --git a/apps/cli/index.ts b/apps/cli/index.ts index ae7f73f870..ad93028579 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -3,20 +3,20 @@ import { suppressPunycodeWarning } from '@studio/common/lib/suppress-punycode-wa import { __, sprintf } from '@wordpress/i18n'; import semver from 'semver'; import yargs from 'yargs'; -import { registerCommand as registerExportCommand } from 'cli/commands/export'; -import { registerCommand as registerImportCommand } from 'cli/commands/import'; -import { registerCommand as registerMcpCommand } from 'cli/commands/mcp'; -import { registerCommand as registerPullCommand } from 'cli/commands/pull'; -import { registerCommand as registerPullReprintCommand } from 'cli/commands/pull-reprint'; -import { registerCommand as registerPushCommand } from 'cli/commands/push'; -import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/create'; -import { registerCommand as registerSiteDeleteCommand } from 'cli/commands/site/delete'; -import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list'; -import { registerCommand as registerSiteStartCommand } from 'cli/commands/site/start'; -import { registerCommand as registerSiteStatusCommand } from 'cli/commands/site/status'; -import { registerCommand as registerSiteStopCommand } from 'cli/commands/site/stop'; -import { registerCommand as registerUiCommand } from 'cli/commands/ui'; -import { registerCommand as registerUninstallCommand } from 'cli/commands/uninstall'; +import { registerCommand as registerExportCommand } from 'cli/commands/export.command'; +import { registerCommand as registerImportCommand } from 'cli/commands/import.command'; +import { registerCommand as registerMcpCommand } from 'cli/commands/mcp.command'; +import { registerCommand as registerPullReprintCommand } from 'cli/commands/pull-reprint.command'; +import { registerCommand as registerPullCommand } from 'cli/commands/pull.command'; +import { registerCommand as registerPushCommand } from 'cli/commands/push.command'; +import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/create.command'; +import { registerCommand as registerSiteDeleteCommand } from 'cli/commands/site/delete.command'; +import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list.command'; +import { registerCommand as registerSiteStartCommand } from 'cli/commands/site/start.command'; +import { registerCommand as registerSiteStatusCommand } from 'cli/commands/site/status.command'; +import { registerCommand as registerSiteStopCommand } from 'cli/commands/site/stop.command'; +import { registerCommand as registerUiCommand } from 'cli/commands/ui.command'; +import { registerCommand as registerUninstallCommand } from 'cli/commands/uninstall.command'; import { bumpAggregatedUniqueStat, bumpStat, diff --git a/apps/cli/tests/startup-imports.test.ts b/apps/cli/tests/startup-imports.test.ts index 13b02b468d..3a70da344e 100644 --- a/apps/cli/tests/startup-imports.test.ts +++ b/apps/cli/tests/startup-imports.test.ts @@ -24,15 +24,26 @@ vi.mock( '@inquirer/prompts', () => { return {}; } ); -// Every `studio` command loads what the CLI entry imports statically before it runs. +// Every `studio` command loads what the CLI entry imports statically before it runs. Each +// command is registered from its `.command` module, which loads the command's implementation +// when the command runs. describe( 'CLI startup', () => { - it( 'loads no heavy module before a command runs', async () => { + it( 'loads no heavy module or command implementation before a command runs', async () => { const entry = readFileSync( path.join( import.meta.dirname, '..', 'index.ts' ), 'utf8' ); const staticImports = [ ...entry.matchAll( /^import [^;]*? from '(cli\/[^']+)';$/gm ) ].map( ( [ , specifier ] ) => specifier ); - expect( staticImports ).toContain( 'cli/commands/mcp' ); + expect( staticImports ).toContain( 'cli/commands/site/create.command' ); + for ( const specifier of staticImports ) { + const implementation = specifier.replace( /\.command$/, '' ); + if ( implementation !== specifier ) { + vi.doMock( implementation, () => { + loadedAtStartup.add( implementation ); + return {}; + } ); + } + } for ( const specifier of staticImports ) { await import( /* @vite-ignore */ specifier ); } diff --git a/apps/cli/types.ts b/apps/cli/types.ts index 3ce0a3500c..f97b2966be 100644 --- a/apps/cli/types.ts +++ b/apps/cli/types.ts @@ -1,7 +1,11 @@ -import { Argv } from 'yargs'; +import { ArgumentsCamelCase, Argv } from 'yargs'; export interface GlobalOptions { path: string; } export type StudioArgv = Argv< GlobalOptions >; + +// The parsed arguments of a command whose options are added by `builder`. +export type CommandArgs< Builder extends ( yargs: StudioArgv ) => Argv< unknown > > = + ArgumentsCamelCase< Builder extends ( yargs: StudioArgv ) => Argv< infer Args > ? Args : never >; From c7f1cef9b959298231a72e1a0cacaebf7ceac13e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 27 Oct 2026 14:21:18 -0400 Subject: [PATCH 2/2] Rerun CI