11import { GluegunToolbox } from "gluegun"
2+ import * as os from "os"
3+ import * as path from "path"
24
35import { p , heading , warning , startSpinner , stopSpinner , clearSpinners } from "../tools/pretty"
46
@@ -14,6 +16,8 @@ interface GitHubRelease {
1416
1517type BuildType = "ios-simulator" | "android-emulator" | "android-device"
1618
19+ const isMac = process . platform === "darwin"
20+
1721module . exports = {
1822 description : "Download pre-built Expo development clients from GitHub releases" ,
1923 run : async function ( toolbox : GluegunToolbox ) {
@@ -48,6 +52,7 @@ module.exports = {
4852 currentRelease = JSON . parse ( currentReleaseResponse )
4953 } catch ( error ) {
5054 // Release doesn't exist or invalid response
55+ warning ( "Failed to parse GitHub release" )
5156 }
5257
5358 const hasAssets = currentRelease ?. assets && currentRelease . assets . length > 0
@@ -70,6 +75,7 @@ module.exports = {
7075 return
7176 }
7277
78+ // In the first iteration of this command, we'll get them all, but eventually we may want to limit this to some reasonable number (maybe?)
7379 let allReleases : GitHubRelease [ ] = [ ]
7480 try {
7581 allReleases = JSON . parse ( allReleasesResponse )
@@ -175,7 +181,274 @@ module.exports = {
175181 process . exit ( 1 )
176182 }
177183
184+ // Install and launch based on build type
185+ if ( buildType === "ios-simulator" ) {
186+ await installAndLaunchIOS ( toolbox , targetPath )
187+ } else if ( buildType === "android-emulator" ) {
188+ await installAndLaunchAndroid ( toolbox , targetPath )
189+ } else {
190+ // We don't support installing directly to an Android device yet, log a message about it.
191+ p ( )
192+ print . info (
193+ "📱 For Android device builds, transfer the APK to your device and install manually." ,
194+ )
195+ p ( )
196+ }
197+
178198 clearSpinners ( )
179199 process . exit ( 0 )
180200 } ,
181201}
202+
203+ async function installAndLaunchIOS ( toolbox : GluegunToolbox , tarPath : string ) {
204+ const { print, system, filesystem } = toolbox
205+
206+ if ( ! isMac ) {
207+ p ( )
208+ warning ( "⚠️ iOS Simulator installation is only available on macOS" )
209+ p ( )
210+ return
211+ }
212+
213+ p ( )
214+ startSpinner ( "Extracting iOS build..." )
215+
216+ // Extract the .tar.gz file
217+ const extractDir = path . join ( os . tmpdir ( ) , `ignite-ios-${ Date . now ( ) } ` )
218+ filesystem . dir ( extractDir )
219+
220+ try {
221+ await system . run ( `tar -xzf "${ tarPath } " -C "${ extractDir } "` )
222+ stopSpinner ( "Extracting iOS build..." , "✅" )
223+ } catch ( error ) {
224+ stopSpinner ( "Extracting iOS build..." , "❌" )
225+ warning ( `Failed to extract: ${ error . message } ` )
226+ return
227+ }
228+
229+ // Find the .app file
230+ const files = filesystem . list ( extractDir ) || [ ]
231+ const appFile = files . find ( ( f ) => f . endsWith ( ".app" ) )
232+
233+ if ( ! appFile ) {
234+ warning ( "No .app file found in archive" )
235+ return
236+ }
237+
238+ const appPath = path . join ( extractDir , appFile )
239+
240+ // Get list of available simulators
241+ startSpinner ( "Finding iOS Simulators..." )
242+ let simulatorList
243+ try {
244+ simulatorList = await system . run ( "xcrun simctl list devices available --json" , { trim : true } )
245+ stopSpinner ( "Finding iOS Simulators..." , "✅" )
246+ } catch ( error ) {
247+ stopSpinner ( "Finding iOS Simulators..." , "❌" )
248+ warning ( "Failed to list simulators. Make sure Xcode is installed." )
249+ return
250+ }
251+
252+ let devices
253+ try {
254+ const simData = JSON . parse ( simulatorList )
255+ devices = simData . devices
256+ } catch ( error ) {
257+ warning ( "Failed to parse simulator list" )
258+ return
259+ }
260+
261+ // Find a booted simulator or boot one
262+ let bootedDevice : { udid : string ; name : string } | null = null
263+
264+ for ( const runtime in devices ) {
265+ const runtimeDevices = devices [ runtime ]
266+ const booted = runtimeDevices . find ( ( d : any ) => d . state === "Booted" )
267+ if ( booted ) {
268+ bootedDevice = { udid : booted . udid , name : booted . name }
269+ break
270+ }
271+ }
272+
273+ // If no booted simulator, boot the first available one
274+ if ( ! bootedDevice ) {
275+ p ( )
276+ startSpinner ( "Booting iOS Simulator..." )
277+
278+ // Find first available iOS (not watchOS or tvOS) simulator
279+ let firstDevice : { udid : string ; name : string ; runtime : string } | null = null
280+ for ( const runtime in devices ) {
281+ if ( runtime . includes ( "iOS" ) && ! runtime . includes ( "watch" ) ) {
282+ const runtimeDevices = devices [ runtime ]
283+ if ( runtimeDevices . length > 0 && runtimeDevices [ 0 ] . isAvailable !== false ) {
284+ firstDevice = {
285+ udid : runtimeDevices [ 0 ] . udid ,
286+ name : runtimeDevices [ 0 ] . name ,
287+ runtime,
288+ }
289+ break
290+ }
291+ }
292+ }
293+
294+ if ( ! firstDevice ) {
295+ stopSpinner ( "Booting iOS Simulator..." , "❌" )
296+ warning ( "No available iOS simulators found" )
297+ return
298+ }
299+
300+ try {
301+ await system . run ( `xcrun simctl boot ${ firstDevice . udid } ` )
302+ // Open Simulator.app
303+ await system . run ( "open -a Simulator" )
304+ stopSpinner ( "Booting iOS Simulator..." , "✅" )
305+ bootedDevice = { udid : firstDevice . udid , name : firstDevice . name }
306+ // Wait a bit for simulator to fully boot
307+ await new Promise ( ( resolve ) => setTimeout ( resolve , 3000 ) )
308+ } catch ( error ) {
309+ stopSpinner ( "Booting iOS Simulator..." , "❌" )
310+ warning ( `Failed to boot simulator: ${ error . message } ` )
311+ return
312+ }
313+ }
314+
315+ // Install app on the booted simulator
316+ p ( )
317+ startSpinner ( `Installing on ${ bootedDevice . name } ...` )
318+ try {
319+ await system . run ( `xcrun simctl install ${ bootedDevice . udid } "${ appPath } "` )
320+ stopSpinner ( `Installing on ${ bootedDevice . name } ...` , "✅" )
321+ } catch ( error ) {
322+ stopSpinner ( `Installing on ${ bootedDevice . name } ...` , "❌" )
323+ warning ( `Failed to install: ${ error . message } ` )
324+ return
325+ }
326+
327+ // Get bundle ID from the app
328+ startSpinner ( "Launching app..." )
329+ try {
330+ const infoPlistPath = path . join ( appPath , "Info.plist" )
331+ const bundleIdOutput = await system . run (
332+ `/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "${ infoPlistPath } "` ,
333+ { trim : true } ,
334+ )
335+ const bundleId = bundleIdOutput . trim ( )
336+
337+ await system . run ( `xcrun simctl launch ${ bootedDevice . udid } ${ bundleId } ` )
338+ stopSpinner ( "Launching app..." , "✅" )
339+
340+ p ( )
341+ print . success ( `🚀 App launched on ${ bootedDevice . name } ` )
342+ p ( )
343+ } catch ( error ) {
344+ stopSpinner ( "Launching app..." , "❌" )
345+ warning ( `App installed but failed to launch: ${ error . message } ` )
346+ p ( )
347+ }
348+
349+ // Cleanup
350+ filesystem . remove ( extractDir )
351+ }
352+
353+ async function installAndLaunchAndroid ( toolbox : GluegunToolbox , apkPath : string ) {
354+ const { print, system } = toolbox
355+
356+ // Check if adb is available
357+ startSpinner ( "Checking for Android SDK..." )
358+ try {
359+ await system . run ( "adb version" )
360+ stopSpinner ( "Checking for Android SDK..." , "✅" )
361+ } catch ( error ) {
362+ stopSpinner ( "Checking for Android SDK..." , "❌" )
363+ warning ( "adb not found. Make sure Android SDK is installed and in your PATH." )
364+ p ( )
365+ return
366+ }
367+
368+ // Get list of devices/emulators
369+ startSpinner ( "Finding Android devices..." )
370+ let deviceList
371+ try {
372+ deviceList = await system . run ( "adb devices" , { trim : true } )
373+ stopSpinner ( "Finding Android devices..." , "✅" )
374+ } catch ( error ) {
375+ stopSpinner ( "Finding Android devices..." , "❌" )
376+ warning ( "Failed to list devices" )
377+ return
378+ }
379+
380+ const lines = deviceList . split ( "\n" ) . slice ( 1 ) // Skip header
381+ const devices = lines
382+ . filter ( ( line ) => line . trim ( ) && line . includes ( "device" ) )
383+ . map ( ( line ) => line . split ( "\t" ) [ 0 ] )
384+
385+ if ( devices . length === 0 ) {
386+ p ( )
387+ warning ( "⚠️ No Android emulators or devices found running." )
388+ print . info ( "Please start an Android emulator and run this command again." )
389+ p ( )
390+ return
391+ }
392+
393+ // Use first device (or could prompt user to select)
394+ const device = devices [ 0 ]
395+ const isEmulator = device . startsWith ( "emulator-" )
396+
397+ p ( )
398+ startSpinner ( `Installing on ${ isEmulator ? "emulator" : "device" } ${ device } ...` )
399+
400+ try {
401+ await system . run ( `adb -s ${ device } install -r "${ apkPath } "` )
402+ stopSpinner ( `Installing on ${ isEmulator ? "emulator" : "device" } ${ device } ...` , "✅" )
403+ } catch ( error ) {
404+ stopSpinner ( `Installing on ${ isEmulator ? "emulator" : "device" } ${ device } ...` , "❌" )
405+ warning ( `Failed to install: ${ error . message } ` )
406+ return
407+ }
408+
409+ // Get package name and launch
410+ startSpinner ( "Launching app..." )
411+ try {
412+ // For Expo dev clients, we know the package name pattern
413+ // Try to extract it from the APK filename first
414+ path . basename ( apkPath )
415+
416+ // Use adb to list packages and find the one we just installed
417+ // This is more reliable than trying to parse the APK
418+ const packagesOutput = await system . run (
419+ `adb -s ${ device } shell pm list packages -3 | grep -E "(expo|ignite)"` ,
420+ { trim : true } ,
421+ )
422+
423+ const lines = packagesOutput . split ( "\n" )
424+ let packageName = null
425+
426+ // Look for expo.modules.devmenu or expo.modules.devclient or host.exp.exponent
427+ for ( const line of lines ) {
428+ const pkg = line . replace ( "package:" , "" ) . trim ( )
429+ if ( pkg . includes ( "host.exp.exponent" ) || pkg . includes ( "expo" ) ) {
430+ packageName = pkg
431+ break
432+ }
433+ }
434+
435+ if ( ! packageName ) {
436+ // Fallback: try common Expo dev client package names
437+ packageName = "host.exp.exponent"
438+ }
439+
440+ // Launch the app - Expo dev client uses .MainActivity
441+ await system . run ( `adb -s ${ device } shell monkey -p ${ packageName } 1` )
442+ stopSpinner ( "Launching app..." , "✅" )
443+
444+ p ( )
445+ print . success ( `🚀 App launched on ${ isEmulator ? "emulator" : "device" } ` )
446+ p ( )
447+ } catch ( error ) {
448+ stopSpinner ( "Launching app..." , "❌" )
449+ warning ( `App installed but failed to launch: ${ error . message } ` )
450+ p ( )
451+ print . info ( "You can manually launch the app from your device." )
452+ p ( )
453+ }
454+ }
0 commit comments