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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,11 @@ The Studio CLI integrates with Studio and uses the same list of sites. Similarly

```bash
studio list
studio list --format json
```

JSON inventory output includes site identity, path, status, runtime, version, and URL fields. It does not include admin passwords or other secrets. To read one site's admin password, run `studio config get admin-password --path <site>` and do not log the value.

To start and stop sites, run these commands:

```bash
Expand Down
6 changes: 4 additions & 2 deletions apps/cli/commands/site/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core';
import { getSiteUrl } from 'cli/lib/cli-config/sites';
import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
import { getLiveSiteOperation } from 'cli/lib/site-operations';
import { omitSiteSecretFields } from 'cli/lib/site-secret-fields';
import { isSiteRunning } from 'cli/lib/site-utils';
import { getColumnWidths, getPrettyPath } from 'cli/lib/utils';
import { Logger, LoggerError } from 'cli/logger';
Expand Down Expand Up @@ -86,9 +87,10 @@ function displaySiteList(

console.log( table.toString() );
} else {
const json = JSON.stringify( data.jsonEntries );
const json = JSON.stringify( data.jsonEntries.map( omitSiteSecretFields ) );
console.log( json );
logger.reportKeyValuePair( 'sites', json );
// IPC consumers (desktop/local) still receive the full record, including credentials.
logger.reportKeyValuePair( 'sites', JSON.stringify( data.jsonEntries ) );
}
}

Expand Down
20 changes: 12 additions & 8 deletions apps/cli/commands/site/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' )
value: string | undefined;
type?: string;
hidden?: boolean;
secret?: boolean;
}[] = [
{
key: __( 'Site URL' ),
Expand Down Expand Up @@ -83,6 +84,7 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' )
key: __( 'Admin password' ),
jsonKey: 'adminPassword',
value: site.adminPassword ? decodePassword( site.adminPassword ) : undefined,
secret: true,
},
{ key: __( 'Admin email' ), jsonKey: 'adminEmail', value: site.adminEmail },
].filter( ( { value, hidden } ) => value && ! hidden );
Expand All @@ -104,14 +106,16 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' )
console.table( table.toString() );
} else {
const logData = Object.fromEntries(
siteData.flatMap( ( { jsonKey, value } ) =>
jsonKey === 'status'
? [
[ jsonKey, value ],
[ 'isOnline', isOnline ],
]
: [ [ jsonKey, value ] ]
)
siteData
.filter( ( { secret } ) => ! secret )
.flatMap( ( { jsonKey, value } ) =>
jsonKey === 'status'
? [
[ jsonKey, value ],
[ 'isOnline', isOnline ],
]
: [ [ jsonKey, value ] ]
)
);

console.log( JSON.stringify( logData, null, 2 ) );
Expand Down
123 changes: 102 additions & 21 deletions apps/cli/commands/site/tests/list.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
import { vi } from 'vitest';
import { readCliConfig } from 'cli/lib/cli-config/core';
import { connectToDaemon, disconnectFromDaemon, listProcesses } from 'cli/lib/daemon-client';
import { SITE_SECRET_FIELD_KEYS } from 'cli/lib/site-secret-fields';
import { isServerRunning } from 'cli/lib/wordpress-server-manager';
import { mockReportKeyValuePair } from 'cli/tests/test-utils';
import { runCommand } from '../list';

const SECRET_KEY_PATTERN = /password|secret|tlsKey|tlsCert/i;

function collectKeys( value: unknown, keys = new Set< string >() ): Set< string > {
if ( Array.isArray( value ) ) {
for ( const item of value ) {
collectKeys( item, keys );
}
} else if ( value && typeof value === 'object' ) {
for ( const [ key, nested ] of Object.entries( value ) ) {
keys.add( key );
collectKeys( nested, keys );
}
}
return keys;
}

vi.mock( 'cli/lib/cli-config/core', async () => {
const actual = await vi.importActual( 'cli/lib/cli-config/core' );
return {
Expand Down Expand Up @@ -93,33 +111,96 @@ describe( 'CLI: studio site list', () => {
} );

it( 'should list sites with json format', async () => {
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( 'json' );

const publicSites = [
{
id: 'site-1',
name: 'Test Site 1',
path: '/path/to/site1',
port: 8080,
phpVersion: '8.0',
url: 'http://localhost:8080',
running: false,
},
{
id: 'site-2',
name: 'Test Site 2',
path: '/path/to/site2',
port: 8081,
phpVersion: '8.0',
customDomain: 'my-site.wp.local',
url: 'http://my-site.wp.local',
running: false,
},
];
expect( consoleSpy ).toHaveBeenCalledWith( JSON.stringify( publicSites ) );
expect( mockReportKeyValuePair ).toHaveBeenCalledWith(
'sites',
JSON.stringify( [
{
id: 'site-1',
name: 'Test Site 1',
path: '/path/to/site1',
port: 8080,
phpVersion: '8.0',
url: 'http://localhost:8080',
running: false,
},
{
id: 'site-2',
name: 'Test Site 2',
path: '/path/to/site2',
port: 8081,
phpVersion: '8.0',
customDomain: 'my-site.wp.local',
url: 'http://my-site.wp.local',
running: false,
},
] )
JSON.stringify( publicSites )
);
expect( disconnectFromDaemon ).toHaveBeenCalled();

consoleSpy.mockRestore();
} );

it( 'omits secret credential fields from default list JSON', async () => {
const plaintextPassword = 'super-secret-admin-password';
const encodedPassword = btoa( plaintextPassword );
vi.mocked( readCliConfig ).mockResolvedValue( {
...testCliConfig,
sites: [
{
...testCliConfig.sites[ 0 ],
adminPassword: encodedPassword,
runtime: 'native-php',
tlsKey: 'TLS_PRIVATE_KEY_MATERIAL',
tlsCert: 'TLS_CERT_MATERIAL',
},
],
} as Awaited< ReturnType< typeof readCliConfig > > );

const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

await runCommand( 'json' );

expect( consoleSpy ).toHaveBeenCalledTimes( 1 );
const stdout = String( consoleSpy.mock.calls[ 0 ][ 0 ] );
const parsed = JSON.parse( stdout ) as Array< Record< string, unknown > >;

expect( parsed ).toHaveLength( 1 );
expect( parsed[ 0 ] ).toMatchObject( {
id: 'site-1',
name: 'Test Site 1',
path: '/path/to/site1',
port: 8080,
phpVersion: '8.0',
runtime: 'native-php',
url: 'http://localhost:8080',
running: false,
} );

for ( const key of collectKeys( parsed ) ) {
expect( key ).not.toMatch( SECRET_KEY_PATTERN );
}
for ( const key of SITE_SECRET_FIELD_KEYS ) {
expect( parsed[ 0 ] ).not.toHaveProperty( key );
}
expect( stdout ).not.toContain( plaintextPassword );
expect( stdout ).not.toContain( encodedPassword );
expect( stdout ).not.toContain( 'TLS_PRIVATE_KEY_MATERIAL' );
expect( stdout ).not.toContain( 'TLS_CERT_MATERIAL' );

const [ , ipcJson ] = mockReportKeyValuePair.mock.calls[ 0 ];
const ipcSites = JSON.parse( ipcJson ) as Array< Record< string, unknown > >;
expect( ipcSites[ 0 ] ).toMatchObject( {
id: 'site-1',
adminPassword: encodedPassword,
} );

consoleSpy.mockRestore();
} );

// Both front ends disable a site's actions on what this reports, so an
Expand Down
24 changes: 22 additions & 2 deletions apps/cli/commands/site/tests/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ describe( 'CLI: studio site status', () => {
wpVersion: '6.4',
xdebug: 'Disabled',
adminUsername: 'admin',
adminPassword: 'password123',
},
null,
2
Expand All @@ -99,6 +98,28 @@ describe( 'CLI: studio site status', () => {
consoleSpy.mockRestore();
} );

it( 'omits secret credential fields from status JSON', async () => {
const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} );

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

const stdout = String( consoleSpy.mock.calls[ 0 ][ 0 ] );
const parsed = JSON.parse( stdout ) as Record< string, unknown >;

expect( parsed ).not.toHaveProperty( 'adminPassword' );
expect( stdout ).not.toContain( 'password123' );
expect( parsed ).toMatchObject( {
siteUrl: 'http://localhost:8080/',
sitePath: '/path/to/site',
phpVersion: '8.0',
runtime: 'Native',
wpVersion: '6.4',
adminUsername: 'admin',
} );

consoleSpy.mockRestore();
} );

it( 'should show online status when server is running', async () => {
vi.mocked( isServerRunning ).mockResolvedValue( {
name: 'test-site',
Expand Down Expand Up @@ -126,7 +147,6 @@ describe( 'CLI: studio site status', () => {
wpVersion: '6.4',
xdebug: 'Disabled',
adminUsername: 'admin',
adminPassword: 'password123',
},
null,
2
Expand Down
11 changes: 11 additions & 0 deletions apps/cli/lib/site-secret-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const SITE_SECRET_FIELD_KEYS = [ 'adminPassword', 'tlsKey', 'tlsCert' ] as const;

export type SiteSecretField = ( typeof SITE_SECRET_FIELD_KEYS )[ number ];

export function omitSiteSecretFields< T extends object >( record: T ): Omit< T, SiteSecretField > {
const publicRecord = { ...record } as T & Partial< Record< SiteSecretField, unknown > >;
for ( const key of SITE_SECRET_FIELD_KEYS ) {
delete publicRecord[ key ];
}
return publicRecord;
}
35 changes: 35 additions & 0 deletions apps/cli/lib/tests/site-secret-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { omitSiteSecretFields, SITE_SECRET_FIELD_KEYS } from '../site-secret-fields';

describe( 'omitSiteSecretFields', () => {
it( 'drops known secret keys and keeps inventory fields', () => {
const publicRecord = omitSiteSecretFields( {
id: 'site-1',
name: 'Test Site',
path: '/path/to/site',
port: 8881,
phpVersion: '8.4',
runtime: 'native-php',
url: 'http://localhost:8881',
running: true,
adminUsername: 'admin',
adminPassword: 'encoded-secret',
tlsKey: 'private-key',
tlsCert: 'certificate',
} );

expect( publicRecord ).toEqual( {
id: 'site-1',
name: 'Test Site',
path: '/path/to/site',
port: 8881,
phpVersion: '8.4',
runtime: 'native-php',
url: 'http://localhost:8881',
running: true,
adminUsername: 'admin',
} );
for ( const key of SITE_SECRET_FIELD_KEYS ) {
expect( publicRecord ).not.toHaveProperty( key );
}
} );
} );
16 changes: 11 additions & 5 deletions skills/studio-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ The `studio` command manages local WordPress sites.

```bash
studio create # Create a new site
studio list # List all sites (--format table|json)
studio status # Show site details (--format table|json)
studio list # List all sites (--format table|json). JSON is inventory-only and never includes passwords or TLS material.
studio status # Show site details (--format table|json). JSON omits admin passwords; use `config get admin-password` for credentials.
studio start # Start a site
studio stop # Stop a site (--all to stop all)
studio delete # Delete one or more sites by path or ID (--files to trash, --dry-run to preview)
Expand All @@ -43,11 +43,17 @@ Without flags in a TTY, the CLI prompts interactively for name, path, WP/PHP ver

### Checking site details

`studio status` shows site URL, auto-login URL, admin credentials, PHP/WP versions, Xdebug status, and online/offline status. Prefer this over individual `wp-cli` calls when you need general site info.
`studio status` shows site URL, auto-login URL, PHP/WP versions, Xdebug status, and online/offline status. Prefer this over individual `wp-cli` calls when you need general site info. Table output may include the admin password for the selected site; JSON never does.

```bash
studio status --path ~/Studio/my-site # Table output
studio status --path ~/Studio/my-site --format json # JSON output (fields: siteUrl, autoLoginUrl, sitePath, status, phpVersion, wpVersion, xdebug, adminUsername, adminPassword, adminEmail)
studio status --path ~/Studio/my-site --format json # JSON output (fields: siteUrl, autoLoginUrl, sitePath, status, phpVersion, runtime, fileAccess, wpVersion, xdebug, adminUsername, adminEmail)
```

Retrieve one site's admin password with an explicit, auditable command. Do not log, pipe into transcripts, or cache the value:

```bash
studio config get admin-password --path ~/Studio/my-site
```

### Reading and changing configuration
Expand Down Expand Up @@ -156,7 +162,7 @@ studio wp --path ~/Studio/my-site user list
## Tips

- Use `--path` to target a specific site directory, or `cd` into the site folder first.
- Use `--format json` on `list`, `status`, `config get`, and `preview list` for machine-readable output. For a single config value, `studio config get <key>` prints it raw (no parsing needed).
- Use `--format json` on `list`, `status`, `config get`, and `preview list` for machine-readable output. `list --format json` and `status --format json` omit passwords and other secrets. For a single config value, `studio config get <key>` prints it raw (no parsing needed). Never log `studio config get admin-password`.
- Run `studio <command> --help` to see all options for any command.
- Custom domains require hosts file changes (may need elevated permissions on macOS/Linux).
- HTTPS uses self-signed certificates stored in platform-specific locations.