Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
2 changes: 1 addition & 1 deletion apps/cli/__mocks__/lib/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { vi } from 'vitest';

export const connectToDaemon = vi.fn().mockResolvedValue( undefined );
export const disconnectFromDaemon = vi.fn().mockResolvedValue( undefined );
export const emitSiteEvent = vi.fn().mockResolvedValue( undefined );
export const emitCliEvent = vi.fn().mockResolvedValue( undefined );
export const killDaemonAndChildren = vi.fn().mockResolvedValue( undefined );
export const listProcesses = vi.fn().mockResolvedValue( [] );
export const getDaemonBus = vi.fn().mockResolvedValue( {} );
Expand Down
38 changes: 29 additions & 9 deletions apps/cli/commands/_events.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 👍

Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@
* stdout key-value pairs that Studio parses.
*/

import {
SITE_EVENTS,
SNAPSHOT_EVENTS,
siteDetailsSchema,
siteSocketEventSchema,
snapshotSocketEventSchema,
SiteEvent,
SnapshotEvent,
} from '@studio/common/lib/cli-events';
import { sequential } from '@studio/common/lib/sequential';
import { SITE_EVENTS, siteDetailsSchema, SiteEvent } from '@studio/common/lib/site-events';
import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import { __ } from '@wordpress/i18n';
import { z } from 'zod';
import { getSiteUrl, readCliConfig, SiteData } from 'cli/lib/cli-config';
import {
connectToDaemon,
Expand Down Expand Up @@ -68,18 +75,31 @@ async function emitAllSitesStopped(): Promise< void > {
}
}

const siteEventSchema = z.object( {
event: z.string(),
data: z.object( {
siteId: z.string(),
} ),
} );
const emitSnapshotEvent = sequential(
async ( event: SNAPSHOT_EVENTS, snapshotUrl: string ): Promise< void > => {
const cliConfig = await readCliConfig();
const snapshot = cliConfig.snapshots.find( ( s ) => s.url === snapshotUrl );
const payload: SnapshotEvent = {
event,
snapshotUrl,
snapshot: snapshot ?? undefined,
};

logger.reportKeyValuePair( 'snapshot-event', JSON.stringify( payload ) );
}
);

export async function runCommand(): Promise< void > {
const eventsSocketServer = new SocketServer( SITE_EVENTS_SOCKET_PATH, 2500 );
eventsSocketServer.on( 'message', ( { message: packet } ) => {
try {
const parsedPacket = siteEventSchema.parse( packet );
const snapshotParsed = snapshotSocketEventSchema.safeParse( packet );
if ( snapshotParsed.success ) {
void emitSnapshotEvent( snapshotParsed.data.event, snapshotParsed.data.data.snapshotUrl );
return;
}

const parsedPacket = siteSocketEventSchema.parse( packet );
if (
parsedPacket.event === SITE_EVENTS.CREATED ||
parsedPacket.event === SITE_EVENTS.UPDATED ||
Expand Down
35 changes: 29 additions & 6 deletions apps/cli/commands/preview/create.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import os from 'os';
import path from 'path';
import { SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events';
import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version';
import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import { __, sprintf } from '@wordpress/i18n';
import { uploadArchive, waitForSiteReady } from 'cli/lib/api';
import { getAuthToken } from 'cli/lib/appdata';
import { archiveSiteContent, cleanup } from 'cli/lib/archive';
import { getSiteByFolder } from 'cli/lib/cli-config';
import { saveSnapshotToAppdata } from 'cli/lib/snapshots';
import { getSiteByFolder, getNextSnapshotSequence } from 'cli/lib/cli-config';
import { emitCliEvent } from 'cli/lib/daemon-client';
import { getSnapshotsFromConfig, saveSnapshotToConfig } from 'cli/lib/snapshots';
import { validateSiteSize } from 'cli/lib/validation';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

export async function runCommand( siteFolder: string ): Promise< void > {
export async function runCommand( siteFolder: string, name?: string ): Promise< void > {
const archivePath = path.join(
os.tmpdir(),
`${ path.basename( siteFolder ) }-${ Date.now() }.zip`
Expand Down Expand Up @@ -42,12 +44,27 @@ export async function runCommand( siteFolder: string ): Promise< void > {
);

logger.reportStart( LoggerAction.APPDATA, __( 'Saving preview site to Studio…' ) );
const snapshot = await saveSnapshotToAppdata(
let snapshotName = name;
if ( ! snapshotName ) {
const site = await getSiteByFolder( siteFolder );
const snapshots = await getSnapshotsFromConfig( token.id );
const sequence = getNextSnapshotSequence( site.id, snapshots, token.id );
snapshotName = sprintf(
/* translators: 1: Site name 2: Sequence number (e.g. "My Site Name Preview 1") */
__( '%1$s Preview %2$d' ),
site.name,
sequence
);
}
const snapshot = await saveSnapshotToConfig(
siteFolder,
uploadResponse.site_id,
uploadResponse.site_url
uploadResponse.site_url,
token.id,
snapshotName
);
logger.reportSuccess( __( 'Preview site saved to Studio' ) );
await emitCliEvent( { event: SNAPSHOT_EVENTS.CREATED, data: { snapshotUrl: snapshot.url } } );

logger.reportKeyValuePair( 'name', snapshot.name ?? '' );
logger.reportKeyValuePair( 'url', snapshot.url );
Expand All @@ -67,8 +84,14 @@ export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'create',
describe: __( 'Create a preview site' ),
builder: ( yargs ) => {
return yargs.option( 'name', {
type: 'string',
description: __( 'Preview site name' ),
} );
},
Comment on lines +88 to +93
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice addition 👍

handler: async ( argv ) => {
await runCommand( argv.path );
await runCommand( argv.path, argv.name );
},
} );
};
12 changes: 9 additions & 3 deletions apps/cli/commands/preview/delete.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events';
import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import { __ } from '@wordpress/i18n';
import { deleteSnapshot } from 'cli/lib/api';
import { getAuthToken } from 'cli/lib/appdata';
import { deleteSnapshotFromAppdata, getSnapshotsFromAppdata } from 'cli/lib/snapshots';
import { emitCliEvent } from 'cli/lib/daemon-client';
import { deleteSnapshotFromConfig, getSnapshotsFromConfig } from 'cli/lib/snapshots';
import { normalizeHostname } from 'cli/lib/utils';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';
Expand All @@ -13,7 +15,7 @@ export async function runCommand( host: string ): Promise< void > {
try {
logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) );
const token = await getAuthToken();
const snapshots = await getSnapshotsFromAppdata( token.id );
const snapshots = await getSnapshotsFromConfig( token.id );
const snapshotToDelete = snapshots.find( ( s ) => s.url === host );
if ( ! snapshotToDelete ) {
throw new LoggerError(
Expand All @@ -27,7 +29,11 @@ export async function runCommand( host: string ): Promise< void > {

logger.reportStart( LoggerAction.DELETE, __( 'Deleting…' ) );
await deleteSnapshot( snapshotToDelete.atomicSiteId, token.accessToken );
await deleteSnapshotFromAppdata( snapshotToDelete.url );
await deleteSnapshotFromConfig( snapshotToDelete.url );
await emitCliEvent( {
event: SNAPSHOT_EVENTS.DELETED,
data: { snapshotUrl: snapshotToDelete.url },
} );
logger.reportSuccess( __( 'Deletion successful' ) );
} catch ( error ) {
if ( error instanceof LoggerError ) {
Expand Down
70 changes: 33 additions & 37 deletions apps/cli/commands/preview/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { __, _n, sprintf } from '@wordpress/i18n';
import Table from 'cli-table3';
import { format } from 'date-fns';
import { getAuthToken } from 'cli/lib/appdata';
import { getSiteByFolder } from 'cli/lib/cli-config';
import { readCliConfig } from 'cli/lib/cli-config/core';
import {
formatDurationUntilExpiry,
getSnapshotsFromAppdata,
getSnapshotsFromConfig,
isSnapshotExpired,
} from 'cli/lib/snapshots';
import { getColumnWidths } from 'cli/lib/utils';
Expand All @@ -20,13 +20,20 @@ export async function runCommand(
const logger = new Logger< LoggerAction >();

try {
if ( outputFormat === 'json' ) {
const config = await readCliConfig();
const json = JSON.stringify( config.snapshots );
console.log( json );
logger.reportKeyValuePair( 'snapshots', json );
return;
}
Comment on lines +23 to +29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also log to stdout here? I know I've argued that using reportKeyValuePair is safer, it just seems odd that of a user passes --format json, they get nothing back…


logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) );
await getSiteByFolder( siteFolder );
const token = await getAuthToken();
logger.reportSuccess( __( 'Validation successful' ), true );

logger.reportStart( LoggerAction.LOAD, __( 'Loading preview sites…' ) );
const snapshots = await getSnapshotsFromAppdata( token.id, siteFolder );
const snapshots = await getSnapshotsFromConfig( token.id, siteFolder );

if ( snapshots.length === 0 ) {
logger.reportSuccess( __( 'No preview sites found' ) );
Expand All @@ -51,42 +58,31 @@ export async function runCommand(
logger.reportSuccess( snapshotsMessage );
}

if ( outputFormat === 'table' ) {
const colWidths = getColumnWidths( [ 0.4, 0.25, 0.175, 0.175 ] );
const table = new Table( {
head: [ __( 'URL' ), __( 'Site Name' ), __( 'Updated' ), __( 'Expires in' ) ],
wordWrap: true,
wrapOnWordBoundary: false,
colWidths,
style: {
head: [],
border: [],
},
} );

for ( const snapshot of snapshots ) {
const durationUntilExpiry = formatDurationUntilExpiry( snapshot.date );
const url = `https://${ snapshot.url }`;

table.push( [
{ href: url, content: url },
snapshot.name,
format( snapshot.date, 'yyyy-MM-dd HH:mm' ),
durationUntilExpiry,
] );
}
const colWidths = getColumnWidths( [ 0.4, 0.25, 0.175, 0.175 ] );
const table = new Table( {
head: [ __( 'URL' ), __( 'Site Name' ), __( 'Updated' ), __( 'Expires in' ) ],
wordWrap: true,
wrapOnWordBoundary: false,
colWidths,
style: {
head: [],
border: [],
},
} );

console.log( table.toString() );
} else {
const output = snapshots.map( ( snapshot ) => ( {
url: `https://${ snapshot.url }`,
name: snapshot.name,
date: format( snapshot.date, 'yyyy-MM-dd HH:mm' ),
expiresIn: formatDurationUntilExpiry( snapshot.date ),
} ) );
for ( const snapshot of snapshots ) {
const durationUntilExpiry = formatDurationUntilExpiry( snapshot.date );
const url = `https://${ snapshot.url }`;

console.log( JSON.stringify( output, null, 2 ) );
table.push( [
{ href: url, content: url },
snapshot.name,
format( snapshot.date, 'yyyy-MM-dd HH:mm' ),
durationUntilExpiry,
] );
}

console.log( table.toString() );
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
Expand Down
68 changes: 68 additions & 0 deletions apps/cli/commands/preview/set.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still expect the CLI and the app to use a shared config file that they can both read from and write to. I don't think we can get away from that. The language setting and the auth data are two good examples of things that the app shouldn't or couldn't use the CLI to write to the shared config.

With that in mind, I'd argue it's a bit overkill to introduce a CLI command just to update the name of a preview site. It's possible that CLI-only users would also want this ability, it just seems much less important in the CLI than it does in the UI.

I'm not necessarily arguing we need to walk back this change – if it works, then it works. Still, I think there are better arguments for why we need the site set command (it lets us consolidate HTTPS certificate logic in the CLI, it allows us to use only the CLI to talk to the process manager daemon, etc). In this case, we're basically just wrapping a file write operation in another process.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was also wondering that during development.
What pushed me to make the decision to add name to edit and create preview is the fact that we show that information in the CLI. It seems strange to me to show a name to our users if we don't allow them to set or edit that name.

Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events';
import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import { __ } from '@wordpress/i18n';
import { setSnapshotInConfig } from 'cli/lib/cli-config';
import { emitCliEvent } from 'cli/lib/daemon-client';
import { normalizeHostname } from 'cli/lib/utils';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

export interface SetCommandOptions {
name?: string;
}

export async function runCommand( host: string, options: SetCommandOptions ): Promise< void > {
const { name } = options;
const logger = new Logger< LoggerAction >();

if ( name === undefined ) {
throw new LoggerError( __( 'At least one option (--name) is required.' ) );
}

if ( name !== undefined && ! name.trim() ) {
throw new LoggerError( __( 'Preview site name cannot be empty.' ) );
}

logger.reportStart( LoggerAction.SET, __( 'Updating preview site…' ) );
await setSnapshotInConfig( host, { name } );
await emitCliEvent( { event: SNAPSHOT_EVENTS.UPDATED, data: { snapshotUrl: host } } );
logger.reportSuccess( __( 'Preview site updated' ) );
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'set <host>',
describe: __( 'Configure preview site settings' ),
builder: ( yargs ) => {
return yargs
.positional( 'host', {
type: 'string',
description: __( 'Hostname of the preview site to configure' ),
demandOption: true,
} )
.option( 'name', {
type: 'string',
description: __( 'Preview site name' ),
} )
.option( 'path', {
hidden: true,
} );
},
handler: async ( argv ) => {
try {
const normalizedHost = normalizeHostname( argv.host );
await runCommand( normalizedHost, { name: argv.name } );
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
const loggerError = new LoggerError( __( 'Failed to configure preview site' ), error );
logger.reportError( loggerError );
}
process.exit( 1 );
}
},
} );
};

const logger = new Logger< LoggerAction >();
Loading