@@ -131,6 +131,53 @@ async function waitUntilUsable(ctx: Ctx, id: string): Promise<DeviceSession> {
131131 }
132132}
133133
134+ /**
135+ * Poll until the device has finished installing `binaryId`: the session
136+ * block's `install.status` leaves `installing`. The session is usable
137+ * throughout (it stays ready while a new build installs), so waiting on the
138+ * state alone returned too early. An API without `install` returns at once.
139+ */
140+ async function waitForInstall ( ctx : Ctx , id : string , binaryId : string ) : Promise < DeviceSession > {
141+ const deadline = Date . now ( ) + WAIT_TIMEOUT_MS ;
142+ for ( ; ; ) {
143+ const { session } = await DeviceGateway . status ( ctx . apiUrl , await freshAuth ( ctx ) , id ) ;
144+ const install = session . install ;
145+ if ( ENDED_STATES . includes ( session . state ) ) {
146+ throw new DeviceApiError (
147+ `Device session ${ id } ended while installing (${ session . release_reason ?? session . state } ).` ,
148+ 410 ,
149+ 'LIVE_SESSION_ENDED' ,
150+ session ,
151+ ) ;
152+ }
153+
154+ // Done, failed, superseded by another install, or an API too old to say.
155+ if ( ! install || install . binary_upload_id !== binaryId || install . status !== 'installing' ) {
156+ return session ;
157+ }
158+
159+ if ( Date . now ( ) >= deadline ) {
160+ throw new DeviceApiError (
161+ `Timed out after ${ WAIT_TIMEOUT_MS / 60_000 } minutes waiting for the install on ${ id } . It may still finish: check it with \`dcd device status ${ id } \`.` ,
162+ 408 ,
163+ null ,
164+ session ,
165+ ) ;
166+ }
167+
168+ await sleep ( WAIT_POLL_MS ) ;
169+ }
170+ }
171+
172+ /** A failed install as a command failure (exit 2); the session stays open. */
173+ function installFailure ( session : DeviceSession ) : { code : null ; message : string } | undefined {
174+ if ( session . install ?. status !== 'failed' ) return undefined ;
175+ return {
176+ code : null ,
177+ message : `The app could not be installed: ${ session . install . error ?? 'unknown error' } Session ${ session . id } is still open.` ,
178+ } ;
179+ }
180+
134181/**
135182 * Turn `<path|url>` into what /install takes: a URL goes to the API's
136183 * install-from-URL, a local file is uploaded first.
@@ -260,15 +307,23 @@ const startSub = defineCommand({
260307
261308 // The session exists and bills from here on: every later failure must
262309 // still name it, so the caller can stop it.
310+ let installedId : string | undefined ;
263311 try {
264312 if ( resolved && 'url' in resolved ) {
265313 ctx . progress ( ui . running ( 'Installing the app from its URL…' ) ) ;
266- ( { session } = await DeviceGateway . install ( ctx . apiUrl , ctx . auth , session . id , resolved ) ) ;
314+ const fromUrl = await DeviceGateway . install ( ctx . apiUrl , ctx . auth , session . id , resolved ) ;
315+ session = fromUrl . session ;
316+ installedId = fromUrl . app . binary_upload_id ;
267317 }
268318
269319 if ( args . wait ) {
270320 ctx . progress ( ui . running ( 'Waiting for the device…' ) ) ;
271321 session = await waitUntilUsable ( ctx , session . id ) ;
322+ const expected = installedId ?? uploadedId ;
323+ if ( expected ) {
324+ ctx . progress ( ui . running ( 'Waiting for the app to install…' ) ) ;
325+ session = await waitForInstall ( ctx , session . id , expected ) ;
326+ }
272327 }
273328 } catch ( error ) {
274329 if ( error instanceof DeviceApiError && error . code === 'LIVE_SESSION_ENDED' ) throw error ;
@@ -280,9 +335,11 @@ const startSub = defineCommand({
280335 throw new DeviceApiError ( message , 500 , null , session ) ;
281336 }
282337
338+ const startFailure = args . wait ? installFailure ( session ) : undefined ;
283339 return {
284340 session,
285- data : { binary_upload_id : uploadedId ?? null } ,
341+ data : { binary_upload_id : installedId ?? uploadedId ?? null } ,
342+ ...( startFailure && { failure : startFailure } ) ,
286343 print ( ) {
287344 logger . log ( ui . section ( 'Device session' ) ) ;
288345 printSession ( session ) ;
@@ -455,14 +512,25 @@ const installSub = defineCommand({
455512 if ( args . wait ) {
456513 ctx . progress ( ui . running ( 'Waiting for the device…' ) ) ;
457514 session = await waitUntilUsable ( ctx , id ) ;
515+ ctx . progress ( ui . running ( 'Waiting for the app to install…' ) ) ;
516+ session = await waitForInstall ( ctx , id , installed . app . binary_upload_id ) ;
458517 }
459518
460519 const appInfo : DeviceInstalledApp = installed . app ;
520+ const failure = args . wait ? installFailure ( session ) : undefined ;
461521 return {
462522 session,
463523 data : { app : appInfo } ,
524+ ...( failure && { failure } ) ,
464525 print ( ) {
465- logger . log ( ui . success ( `Installing ${ appInfo . app_id ?? appInfo . binary_upload_id } ` ) ) ;
526+ if ( failure ) {
527+ logger . log ( `${ ui . statusSymbol ( 'FAILED' ) } ${ failure . message } ` ) ;
528+ return ;
529+ }
530+
531+ logger . log (
532+ ui . success ( `${ args . wait ? 'Installed' : 'Installing' } ${ appInfo . app_id ?? appInfo . binary_upload_id } ` ) ,
533+ ) ;
466534 logger . log (
467535 ui . branch (
468536 ui . fields ( [
0 commit comments