Skip to content

Commit 322117a

Browse files
feat(cli): add devclient command to download prebuilt artifacts (by @coolsoftwaretyler)
1 parent 797c87c commit 322117a

1 file changed

Lines changed: 273 additions & 0 deletions

File tree

src/commands/devclient.ts

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { GluegunToolbox } from "gluegun"
2+
import * as os from "os"
3+
import * as path from "path"
24

35
import { p, heading, warning, startSpinner, stopSpinner, clearSpinners } from "../tools/pretty"
46

@@ -14,6 +16,8 @@ interface GitHubRelease {
1416

1517
type BuildType = "ios-simulator" | "android-emulator" | "android-device"
1618

19+
const isMac = process.platform === "darwin"
20+
1721
module.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

Comments
 (0)