diff --git a/apps/cli/index.ts b/apps/cli/index.ts index ad93028579..2d7a49351f 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -331,6 +331,12 @@ async function main() { .strict(); await studioArgv.argv; + + // Tell the host that started this command it has finished, so it needn't wait for the + // process to exit after best-effort telemetry requests complete. + if ( process.connected && ! process.exitCode ) { + process.send?.( { action: 'done' } ); + } } void main(); diff --git a/packages/common/lib/cli-process.ts b/packages/common/lib/cli-process.ts index 267fdfd5b0..b1c9069ccb 100644 --- a/packages/common/lib/cli-process.ts +++ b/packages/common/lib/cli-process.ts @@ -84,6 +84,16 @@ const cliErrorMessageSchema = z.object( { message: z.string(), } ); +/** + * Sent by the CLI once a command has finished successfully. Its process can stay alive a while + * longer to finish sending best-effort telemetry, which its host doesn't need to wait for. + */ +const cliDoneMessageSchema = z.object( { action: z.literal( 'done' ) } ); + +export function isCliCommandDone( message: unknown ): boolean { + return cliDoneMessageSchema.safeParse( message ).success; +} + type CliCommandEventEmitter< CapturesOutput extends boolean > = TypedEventEmitter< CliCommandEventMap< CapturesOutput > >; @@ -327,7 +337,8 @@ const cliProgressMessageSchema = z.object( { /** * Runs a CLI command and resolves with the result it reports (`logger.reportResult`), parsed by - * `schema`. Rejects with the command's error, or when it succeeds without reporting a result. + * `schema`, once the command has finished. Rejects with the command's error, or when it succeeds + * without reporting a result. */ export function runCliCommand< T >( execute: ExecuteCliCommand, @@ -338,27 +349,32 @@ export function runCliCommand< T >( return new Promise( ( resolve, reject ) => { let result: z.ZodSafeParseResult< T > | undefined; const [ emitter ] = execute( args, { output: 'capture', logPrefix: options.logPrefix } ); + const settle = () => { + if ( result?.success ) { + resolve( result.data ); + } else { + reject( + result?.error ?? + new Error( `\`studio ${ args.slice( 0, 2 ).join( ' ' ) }\` reported no result.` ) + ); + } + }; emitter.on( 'data', ( { data } ) => { const message = cliResultMessageSchema.safeParse( data ); if ( message.success ) { result = schema.safeParse( message.data.value ); return; } + if ( isCliCommandDone( data ) ) { + settle(); + return; + } const progress = cliProgressMessageSchema.safeParse( data ); if ( progress.success ) { options.onProgress?.( progress.data.message ); } } ); - emitter.on( 'success', () => { - if ( result?.success ) { - resolve( result.data ); - } else { - reject( - result?.error ?? - new Error( `\`studio ${ args.slice( 0, 2 ).join( ' ' ) }\` reported no result.` ) - ); - } - } ); + emitter.on( 'success', settle ); emitter.on( 'failure', ( { error } ) => reject( error ) ); emitter.on( 'error', ( { error } ) => reject( error ) ); } ); diff --git a/packages/common/lib/tests/cli-process.test.ts b/packages/common/lib/tests/cli-process.test.ts index 7b81e7724a..fd0e044521 100644 --- a/packages/common/lib/tests/cli-process.test.ts +++ b/packages/common/lib/tests/cli-process.test.ts @@ -115,6 +115,21 @@ describe( 'runCliCommand', () => { ); } ); + it( 'resolves once the command says it finished, while its process is still running', async () => { + const emitter = new EventEmitter(); + const execute = vi.fn( () => { + queueMicrotask( () => { + emitter.emit( 'data', { data: { action: 'result', value: { url: 'https://a.example' } } } ); + emitter.emit( 'data', { data: { action: 'done' } } ); + } ); + return [ emitter, {} ]; + } ) as unknown as ExecuteCliCommand; + + await expect( runCliCommand( execute, [ 'site', 'create' ], schema ) ).resolves.toEqual( { + url: 'https://a.example', + } ); + } ); + it( 'rejects with the command error when it fails', async () => { const emitter = new EventEmitter(); const execute = vi.fn( () => { diff --git a/packages/common/sites/snapshots.ts b/packages/common/sites/snapshots.ts index 8f75904393..295fd50637 100644 --- a/packages/common/sites/snapshots.ts +++ b/packages/common/sites/snapshots.ts @@ -1,6 +1,10 @@ import crypto from 'node:crypto'; import { z } from 'zod'; -import { runCliCommand, type ExecuteCliCommand } from '@studio/common/lib/cli-process'; +import { + isCliCommandDone, + runCliCommand, + type ExecuteCliCommand, +} from '@studio/common/lib/cli-process'; import { PreviewCommandLoggerAction } from '@studio/common/logger-actions'; import { snapshotSchema, type Snapshot } from '@studio/common/types/snapshot'; @@ -60,6 +64,9 @@ export function createSnapshotManager( ctx: SnapshotCommandContext ): SnapshotMa const [ emitter ] = ctx.executeCliCommand( args, { output: 'capture', logPrefix: 'preview' } ); emitter.on( 'data', ( { data } ) => { + if ( isCliCommandDone( data ) ) { + return; + } const parsed = snapshotEventSchema.safeParse( data ); if ( ! parsed.success ) { console.error( 'Invalid snapshot event:', parsed.error );