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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,26 @@ studio --help
From anywhere on your system, run the following command to create a new WordPress site (with a step-by-step guide):

```bash
studio site create
studio create
```

## Usage

The Studio CLI integrates with Studio and uses the same list of sites. Similarly to Studio, the Studio CLI also runs sites in the background. To see the list of sites under management by Studio and their current status, run the command:

```bash
studio site list
studio list
```

To start and stop sites, run these commands:

```bash
studio site start --path ~/Studio/my-site
studio site stop --path ~/Studio/my-site
studio start --path ~/Studio/my-site
studio stop --path ~/Studio/my-site
```

> These site commands used to live under a `site` group (e.g. `studio site list`). That group is still accepted as a hidden alias for backward compatibility, but the top-level commands above are preferred. Site settings now live under `studio config get` / `studio config set`.

Run WP-CLI commands in a site:

```bash
Expand Down
137 changes: 137 additions & 0 deletions apps/cli/commands/config/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version';
import { decodePassword } from '@studio/common/lib/passwords';
import { getSiteFileAccess } from '@studio/common/lib/site-file-access';
import { getSiteRuntime, siteModeFromRuntime } from '@studio/common/lib/site-runtime';
import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import { __, sprintf } from '@wordpress/i18n';
import CliTable3 from 'cli-table3';
import { type SiteData } from 'cli/lib/cli-config/core';
import { getSiteByFolder } from 'cli/lib/cli-config/sites';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

const logger = new Logger< LoggerAction >();

type ConfigValue = string | boolean | undefined;

interface ConfigEntry {
key: string;
value: ConfigValue;
}

// The settable knobs exposed by `studio config set`, keyed and ordered to match
// that command's flags so the output round-trips: every key here can be read
// back with `config get <key>` and written with `config set --<key>`. This is
// intentionally narrower than the runtime-oriented `status` output (no URLs, no
// online state). `runtime` is reported as the user-facing mode (`native`/
// `sandbox`) rather than the internal runtime (`native-php`/`playground`).
function getConfigEntries( site: SiteData ): ConfigEntry[] {
return [
{ key: 'name', value: site.name },
{ key: 'domain', value: site.customDomain },
{ key: 'https', value: site.enableHttps ?? false },
{ key: 'php', value: site.phpVersion },
{ key: 'wp', value: getWordPressVersion( site.path ) },
{ key: 'runtime', value: siteModeFromRuntime( getSiteRuntime( site ) ) },
{ key: 'file-access', value: getSiteFileAccess( site ) },
{ key: 'xdebug', value: site.enableXdebug ?? false },
{ key: 'admin-username', value: site.adminUsername ?? 'admin' },
{
key: 'admin-password',
value: site.adminPassword ? decodePassword( site.adminPassword ) : undefined,
},
{ key: 'admin-email', value: site.adminEmail },
{ key: 'debug-log', value: site.enableDebugLog ?? false },
{ key: 'debug-display', value: site.enableDebugDisplay ?? false },
];
}

function formatValue( value: ConfigValue ): string {
if ( value === undefined ) {
return '';
}
return String( value );
}

export async function runCommand(
siteFolder: string,
key: string | undefined,
format: 'table' | 'json'
): Promise< void > {
const site = await getSiteByFolder( siteFolder );
const entries = getConfigEntries( site );

// Single-key lookup: print the raw value with no formatting so it can be
// consumed directly in scripts (e.g. `studio config get php`).
if ( key !== undefined ) {
const entry = entries.find( ( candidate ) => candidate.key === key );
if ( ! entry ) {
throw new LoggerError(
sprintf(
/* translators: 1: requested config key, 2: comma-separated list of valid keys */
__( 'Unknown config key "%1$s". Valid keys: %2$s' ),
key,
entries.map( ( candidate ) => candidate.key ).join( ', ' )
)
);
}
console.log( formatValue( entry.value ) );
return;
}

if ( format === 'json' ) {
const data = Object.fromEntries(
entries.map( ( { key: entryKey, value } ) => [ entryKey, value ?? null ] )
);
console.log( JSON.stringify( data, null, 2 ) );
return;
}

const table = new CliTable3( {
wordWrap: true,
wrapOnWordBoundary: false,
style: {
head: [],
border: [],
},
} );

for ( const { key: entryKey, value } of entries ) {
table.push( [ entryKey, formatValue( value ) ] );
}

console.table( table.toString() );
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'get [key]',
describe: __( 'Get site configuration' ),
builder: ( yargs ) => {
return yargs
.positional( 'key', {
type: 'string',
describe: __( 'Specific setting to read (omit to list all settings)' ),
} )
.option( 'format', {
type: 'string',
choices: [ 'table', 'json' ] as const,
default: 'table' as const,
description: __( 'Output format (ignored when a key is provided)' ),
} );
},
handler: async ( argv ) => {
try {
await runCommand( argv.path, argv.key, argv.format );
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
const loggerError = new LoggerError( __( 'Failed to read site configuration' ), error );
logger.reportError( loggerError );
}
process.exit( 1 );
}
},
} );
};
File renamed without changes.
201 changes: 201 additions & 0 deletions apps/cli/commands/config/tests/get.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version';
import { vi } from 'vitest';
import { getSiteByFolder } from 'cli/lib/cli-config/sites';
import { runCommand } from '../get';

vi.mock( 'cli/lib/cli-config/sites', async () => {
const actual = await vi.importActual( 'cli/lib/cli-config/sites' );
return {
...actual,
getSiteByFolder: vi.fn(),
};
} );
vi.mock( '@studio/common/lib/get-wordpress-version' );

describe( 'CLI: studio config get', () => {
const testSite = {
id: 'site-1',
name: 'Test Site',
path: '/path/to/site',
port: 8080,
phpVersion: '8.0',
enableHttps: true,
customDomain: 'my-site.local',
enableXdebug: false,
adminUsername: 'root',
// btoa-encoded password (decodePassword decodes Base64)
adminPassword: btoa( 'password123' ),
adminEmail: 'admin@example.com',
enableDebugLog: true,
enableDebugDisplay: false,
};

beforeEach( () => {
vi.clearAllMocks();
vi.mocked( getSiteByFolder ).mockResolvedValue( testSite );
vi.mocked( getWordPressVersion ).mockReturnValue( '6.4' );
} );

afterEach( () => {
vi.restoreAllMocks();
} );

describe( 'Single-key lookup', () => {
it( 'prints a raw string value with no formatting', async () => {
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( '/path/to/site', 'php', 'table' );

expect( getSiteByFolder ).toHaveBeenCalledWith( '/path/to/site' );
expect( consoleSpy ).toHaveBeenCalledTimes( 1 );
expect( consoleSpy ).toHaveBeenCalledWith( '8.0' );
} );

it( 'reads the WordPress version from disk', async () => {
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( '/path/to/site', 'wp', 'table' );

expect( consoleSpy ).toHaveBeenCalledWith( '6.4' );
} );

it( 'prints booleans as true/false', async () => {
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( '/path/to/site', 'https', 'table' );

expect( consoleSpy ).toHaveBeenCalledWith( 'true' );
} );

it( 'decodes the admin password', async () => {
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( '/path/to/site', 'admin-password', 'table' );

expect( consoleSpy ).toHaveBeenCalledWith( 'password123' );
} );

it( 'prints an empty line for an unset value', async () => {
vi.mocked( getSiteByFolder ).mockResolvedValue( {
...testSite,
adminEmail: undefined,
} );
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( '/path/to/site', 'admin-email', 'table' );

expect( consoleSpy ).toHaveBeenCalledWith( '' );
} );

it( 'reports the user-facing runtime mode, not the internal runtime', async () => {
vi.mocked( getSiteByFolder ).mockResolvedValue( {
...testSite,
runtime: 'playground',
} );
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( '/path/to/site', 'runtime', 'table' );

expect( consoleSpy ).toHaveBeenCalledWith( 'sandbox' );
} );

it( 'defaults runtime to native when unset', async () => {
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( '/path/to/site', 'runtime', 'table' );

expect( consoleSpy ).toHaveBeenCalledWith( 'native' );
} );

it( 'reports the file access value', async () => {
vi.mocked( getSiteByFolder ).mockResolvedValue( {
...testSite,
fileAccess: 'all-files',
} );
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( '/path/to/site', 'file-access', 'table' );

expect( consoleSpy ).toHaveBeenCalledWith( 'all-files' );
} );

it( 'throws on an unknown key', async () => {
await expect( runCommand( '/path/to/site', 'nope', 'table' ) ).rejects.toThrow(
/Unknown config key "nope"/
);
} );
} );

describe( 'Listing all settings', () => {
it( 'outputs every settable key as JSON', async () => {
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( '/path/to/site', undefined, 'json' );

expect( consoleSpy ).toHaveBeenCalledWith(
JSON.stringify(
{
name: 'Test Site',
domain: 'my-site.local',
https: true,
php: '8.0',
wp: '6.4',
runtime: 'native',
'file-access': 'site-directory',
xdebug: false,
'admin-username': 'root',
'admin-password': 'password123',
'admin-email': 'admin@example.com',
'debug-log': true,
'debug-display': false,
},
null,
2
)
);
} );

it( 'falls back to defaults for unset values in JSON', async () => {
vi.mocked( getSiteByFolder ).mockResolvedValue( {
id: 'site-2',
name: 'Bare Site',
path: '/path/to/bare',
port: 8081,
phpVersion: '8.2',
} );
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( '/path/to/bare', undefined, 'json' );

expect( consoleSpy ).toHaveBeenCalledWith(
JSON.stringify(
{
name: 'Bare Site',
domain: null,
https: false,
php: '8.2',
wp: '6.4',
runtime: 'native',
'file-access': 'site-directory',
xdebug: false,
'admin-username': 'admin',
'admin-password': null,
'admin-email': null,
'debug-log': false,
'debug-display': false,
},
null,
2
)
);
} );

it( 'renders a table by default', async () => {
const tableSpy = vi.spyOn( console, 'table' ).mockImplementation( () => {} );

await runCommand( '/path/to/site', undefined, 'table' );

expect( tableSpy ).toHaveBeenCalledTimes( 1 );
} );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ vi.mock( 'cli/lib/run-wp-cli-command' );
vi.mock( 'cli/lib/site-utils' );
vi.mock( 'cli/lib/wordpress-server-manager' );

describe( 'CLI: studio site set', () => {
describe( 'CLI: studio config set', () => {
const testSitePath = '/test/site';

const getTestSite = (): SiteData => ( {
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/commands/pull-reprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ async function abortPull( url: string, providedName?: string, verbose = false ):

if ( metadata.stage === 'completed' ) {
throw new LoggerError(
__( 'This pull has already completed. Use `studio site delete` to remove the site.' )
__( 'This pull has already completed. Use `studio delete` to remove the site.' )
);
}

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/commands/site/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ export async function runCommand(
if ( ! options.skipLogDetails ) {
logSiteDetails( siteDetails );
}
console.log( __( 'Run "studio site start" to start the site.' ) );
console.log( __( 'Run "studio start" to start the site.' ) );
}

logger.reportKeyValuePair( 'id', siteDetails.id );
Expand Down
Loading