Repository: Automattic/studio
Pull request #1286: Add `--format` option to `preview list` command
Author: login: fredrikekelund
Created: 2025-04-28T13:15:40Z | Merged: 2025-04-28T13:55:26Z

<!--
Link a related issue to this PR. If the PR does not immediately resolve the issue,
for example, it requires a separate deployment to production, avoid
using the "Fixes" keyword and use "Related to" instead.
-->

## Related issues

- Related to pdtkmj-3Am-p2#comment-6901

## Proposed Changes

Added a `--format` option to the `studio preview list` CLI command to allow users to output preview sites in a JSON format. 

The `--output-format` option is already being used for a different purpose, and I don't think it makes sense to mix them up. 

## Testing Instructions

<!--
Add as many details as possible to help others reproduce the issue and test the fix.
"Before / After" screenshots can also be very helpful when the change is visual.
-->

1. Run `npm run cli:build`
2. Run `node dist/cli/main.js preview list PATH --format json` where `PATH` is the path to a Studio site with at least one preview site
3. Ensure that the preview sites are printed in formatted JSON

## Pre-merge Checklist

<!--
Complete applicable items on this checklist **before** merging into trunk. Inapplicable items can be left unchecked.

Both the PR author and reviewer are responsible for ensuring the checklist is completed.
-->

- [ ] Have you checked for TypeScript, React or other console errors?


Diff:
diff --git a/cli/commands/preview/create.ts b/cli/commands/preview/create.ts
index 255b1f3436..2e9cc982f0 100644
--- a/cli/commands/preview/create.ts
+++ b/cli/commands/preview/create.ts
@@ -73,7 +73,7 @@ export const registerCommand = ( yargs: StudioArgv ) => {
 			return yargs.positional( 'folder', {
 				type: 'string',
 				default: process.cwd(),
-				description: __( 'The folder to create a preview from' ),
+				description: __( 'The folder to create a preview site from' ),
 			} );
 		},
 		handler: async ( argv ) => {
diff --git a/cli/commands/preview/list.ts b/cli/commands/preview/list.ts
index 7bdea3d5aa..00f30ed9c4 100644
--- a/cli/commands/preview/list.ts
+++ b/cli/commands/preview/list.ts
@@ -1,13 +1,18 @@
 import { __, _n, sprintf } from '@wordpress/i18n';
 import { PreviewCommandLoggerAction as LoggerAction } from 'common/logger-actions';
 import { getAuthToken } from 'cli/lib/appdata';
-import { getSnapshotCliTable, getSnapshotsFromAppdata } from 'cli/lib/snapshots';
+import {
+	getSnapshotCliJson,
+	getSnapshotCliTable,
+	getSnapshotsFromAppdata,
+} from 'cli/lib/snapshots';
 import { validateSiteFolder } from 'cli/lib/validation';
 import { Logger, LoggerError } from 'cli/logger';
 import { OutputFormat, StudioArgv } from 'cli/types';
 
 export async function runCommand(
 	siteFolder: string,
+	format: 'table' | 'json',
 	outputFormat?: OutputFormat
 ): Promise< void > {
 	const logger = new Logger< LoggerAction >( outputFormat );
@@ -18,25 +23,32 @@ export async function runCommand(
 		const token = await getAuthToken();
 		logger.reportSuccess( __( 'Validation successful' ) );
 
-		logger.reportStart( LoggerAction.LOAD, __( 'Loading previews...' ) );
+		logger.reportStart( LoggerAction.LOAD, __( 'Loading preview sites...' ) );
 		const snapshots = await getSnapshotsFromAppdata( token.id, siteFolder );
 
 		if ( snapshots.length === 0 ) {
-			logger.reportSuccess( __( 'No previews found' ) );
+			logger.reportSuccess( __( 'No preview sites found' ) );
 			return;
 		}
 
 		logger.reportSuccess(
-			sprintf( _n( 'Found %d preview', 'Found %d previews', snapshots.length ), snapshots.length )
+			sprintf(
+				_n( 'Found %d preview site', 'Found %d preview sites', snapshots.length ),
+				snapshots.length
+			)
 		);
 
-		const table = getSnapshotCliTable( snapshots );
-		console.log( table.toString() );
+		if ( format === 'table' ) {
+			const table = getSnapshotCliTable( snapshots );
+			console.log( table.toString() );
+		} else {
+			console.log( JSON.stringify( getSnapshotCliJson( snapshots ), null, 2 ) );
+		}
 	} catch ( error ) {
 		if ( error instanceof LoggerError ) {
 			logger.reportError( error );
 		} else {
-			const loggerError = new LoggerError( __( 'Failed to load previews' ), error );
+			const loggerError = new LoggerError( __( 'Failed to load preview sites' ), error );
 			logger.reportError( loggerError );
 		}
 	}
@@ -47,14 +59,21 @@ export const registerCommand = ( yargs: StudioArgv ) => {
 		command: 'list [folder]',
 		describe: __( 'List preview sites for the specified folder (defaults to current directory)' ),
 		builder: ( yargs ) => {
-			return yargs.positional( 'folder', {
-				type: 'string',
-				default: process.cwd(),
-				description: __( 'The folder to list previews for' ),
-			} );
+			return yargs
+				.positional( 'folder', {
+					type: 'string',
+					default: process.cwd(),
+					description: __( 'The folder to list preview sites for' ),
+				} )
+				.option( 'format', {
+					type: 'string',
+					choices: [ 'table', 'json' ],
+					default: 'table',
+					description: __( 'Output format' ),
+				} );
 		},
 		handler: async ( argv ) => {
-			await runCommand( argv.folder, argv.outputFormat );
+			await runCommand( argv.folder, argv.format as 'table' | 'json', argv.outputFormat );
 		},
 	} );
 };
diff --git a/cli/commands/preview/tests/list.test.ts b/cli/commands/preview/tests/list.test.ts
index 1990feba5b..09fec17444 100644
--- a/cli/commands/preview/tests/list.test.ts
+++ b/cli/commands/preview/tests/list.test.ts
@@ -58,14 +58,17 @@ describe( 'Preview List Command', () => {
 
 	it( 'should list preview sites successfully', async () => {
 		const { runCommand } = await import( '../list' );
-		await runCommand( mockFolder );
+		await runCommand( mockFolder, 'table' );
 
 		expect( validateSiteFolder ).toHaveBeenCalledWith( mockFolder );
 		expect( getSnapshotsFromAppdata ).toHaveBeenCalledWith( mockAuthToken.id, mockFolder );
 		expect( mockLogger.reportStart.mock.calls[ 0 ] ).toEqual( [ 'validate', 'Validating...' ] );
 		expect( mockLogger.reportSuccess.mock.calls[ 0 ] ).toEqual( [ 'Validation successful' ] );
-		expect( mockLogger.reportStart.mock.calls[ 1 ] ).toEqual( [ 'load', 'Loading previews...' ] );
-		expect( mockLogger.reportSuccess.mock.calls[ 1 ] ).toEqual( [ 'Found 2 previews' ] );
+		expect( mockLogger.reportStart.mock.calls[ 1 ] ).toEqual( [
+			'load',
+			'Loading preview sites...',
+		] );
+		expect( mockLogger.reportSuccess.mock.calls[ 1 ] ).toEqual( [ 'Found 2 preview sites' ] );
 	} );
 
 	it( 'should handle validation errors', async () => {
@@ -74,7 +77,7 @@ describe( 'Preview List Command', () => {
 			throw new Error( 'Invalid site folder' );
 		} );
 
-		await runCommand( mockFolder );
+		await runCommand( mockFolder, 'table' );
 
 		expect( mockLogger.reportError ).toHaveBeenCalled();
 	} );
@@ -83,11 +86,14 @@ describe( 'Preview List Command', () => {
 		const { runCommand } = await import( '../list' );
 		( getSnapshotsFromAppdata as jest.Mock ).mockResolvedValue( [] );
 
-		await runCommand( mockFolder );
+		await runCommand( mockFolder, 'table' );
 
 		expect( mockLogger.reportStart.mock.calls[ 0 ] ).toEqual( [ 'validate', 'Validating...' ] );
 		expect( mockLogger.reportSuccess.mock.calls[ 0 ] ).toEqual( [ 'Validation successful' ] );
-		expect( mockLogger.reportStart.mock.calls[ 1 ] ).toEqual( [ 'load', 'Loading previews...' ] );
-		expect( mockLogger.reportSuccess.mock.calls[ 1 ] ).toEqual( [ 'No previews found' ] );
+		expect( mockLogger.reportStart.mock.calls[ 1 ] ).toEqual( [
+			'load',
+			'Loading preview sites...',
+		] );
+		expect( mockLogger.reportSuccess.mock.calls[ 1 ] ).toEqual( [ 'No preview sites found' ] );
 	} );
 } );
diff --git a/cli/commands/preview/update.ts b/cli/commands/preview/update.ts
index d71e5a1fd7..a7e9d2e955 100644
--- a/cli/commands/preview/update.ts
+++ b/cli/commands/preview/update.ts
@@ -79,7 +79,7 @@ export const registerCommand = ( yargs: Argv< GlobalOptions > ) => {
 				.positional( 'folder', {
 					type: 'string',
 					default: process.cwd(),
-					description: __( 'The folder to update the preview from' ),
+					description: __( 'The folder to update the preview site from' ),
 				} )
 				.option( 'host', {
 					alias: 'H',
diff --git a/cli/lib/snapshots.ts b/cli/lib/snapshots.ts
index 5703e7e5f3..3a16daeccd 100644
--- a/cli/lib/snapshots.ts
+++ b/cli/lib/snapshots.ts
@@ -188,3 +188,12 @@ export function getSnapshotCliTable( snapshots: Snapshot[] ) {
 
 	return table;
 }
+
+export function getSnapshotCliJson( snapshots: Snapshot[] ) {
+	return snapshots.map( ( snapshot ) => ( {
+		url: `https://${ snapshot.url }`,
+		name: snapshot.name,
+		date: format( snapshot.date, 'yyyy-MM-dd HH:mm' ),
+		expiresIn: formatDurationUntilExpiry( snapshot.date ),
+	} ) );
+}


Reviews:
user: login: fredrikekelund
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T13:16:25Z
user: login: ivan-ottinger
body: Thank you for implementing the JSON format, Fredrik! 🙂

The proposed changes look good and work nicely!

```bash
node dist/cli/main.js preview list /Users/ivanottinger/Studio/my-wordpress-website --format json
Would have bumped stat: studio-cli-usage-unique=success
✔ Validation successful
✔ Found 2 preview sites
[
  {
    "url": "https://onetwothree-tcqze-studio.wp.build",
    "name": "My WordPress Website xc Preview 1",
    "date": "2025-04-28 11:17",
    "expiresIn": "6 days, 20 hours"
  },
  {
    "url": "https://onetwothree-pxebk-studio.wp.build",
    "name": "My WordPress Website xc Preview 2",
    "date": "2025-04-28 11:22",
    "expiresIn": "6 days, 20 hours"
  }
]
```

If no format is specified, the default `table` format is used. 👍🏼 

Approving. Just left two minor comments.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T13:31:27Z

Review comments:
diff_hunk: @@ -27,11 +32,18 @@ export async function runCommand(
 		}
 
 		logger.reportSuccess(
-			sprintf( _n( 'Found %d preview', 'Found %d previews', snapshots.length ), snapshots.length )
+			sprintf(
+				_n( 'Found %d preview site', 'Found %d preview sites', snapshots.length ),
+				snapshots.length
+			)
 		);
path: cli/commands/preview/list.ts
user: login: fredrikekelund
body: Not directly related to the main changes in this PR, but I thought spelling out "preview site/-s" was more idiomatic than calling them just "preview". 
created_at: 2025-04-28T13:16:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,14 +59,21 @@ export const registerCommand = ( yargs: StudioArgv ) => {
 		command: 'list [folder]',
 		describe: __( 'List preview sites for the specified folder (defaults to current directory)' ),
 		builder: ( yargs ) => {
-			return yargs.positional( 'folder', {
-				type: 'string',
-				default: process.cwd(),
-				description: __( 'The folder to list previews for' ),
-			} );
+			return yargs
+				.positional( 'folder', {
+					type: 'string',
+					default: process.cwd(),
+					description: __( 'The folder to list previews for' ),
path: cli/commands/preview/list.ts
user: login: ivan-ottinger
body: I wonder if it would make sense to make the following changes as well:

```suggestion
					description: __( 'The folder to list preview sites for' ),
```
created_at: 2025-04-28T13:30:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,14 +59,21 @@ export const registerCommand = ( yargs: StudioArgv ) => {
 		command: 'list [folder]',
 		describe: __( 'List preview sites for the specified folder (defaults to current directory)' ),
 		builder: ( yargs ) => {
-			return yargs.positional( 'folder', {
-				type: 'string',
-				default: process.cwd(),
-				description: __( 'The folder to list previews for' ),
-			} );
+			return yargs
+				.positional( 'folder', {
+					type: 'string',
+					default: process.cwd(),
+					description: __( 'The folder to list previews for' ),
+				} )
+				.option( 'format', {
+					type: 'string',
+					choices: [ 'table', 'json' ],
+					default: 'table',
+					description: __( 'The format to output the previews in' ),
path: cli/commands/preview/list.ts
user: login: ivan-ottinger
body: Similar to previous comment:

```suggestion
					description: __( 'The format to output the preview sites in' ),
```
created_at: 2025-04-28T13:30:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: fredrikekelund
created_at: 2025-04-28T13:39:26Z
body: Good catch with the additional strings, @ivan-ottinger 👍 I updated all remaining instances of `previews` I could find
author_association: CONTRIBUTOR
user: login: ivan-ottinger
created_at: 2025-04-28T13:48:39Z
body: > Good catch with the additional strings, @ivan-ottinger 👍 I updated all remaining instances of `previews` I could find

Thanks, Fredrik. Those string updates look good.
author_association: CONTRIBUTOR