expo-release 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run.ts CHANGED
@@ -1,22 +1,166 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
1
4
  export type CommandRunner = (command: string[]) => Promise<string>
2
5
 
6
+ /**
7
+ * The app's own eas-cli (node_modules/.bin, searched up to the repository
8
+ * root) wins over PATH: its lockfile pins the version. `EAS_BIN` wins over both.
9
+ */
3
10
  export function easCommand(...args: string[]): string[] {
4
11
  const bin = process.env.EAS_BIN?.trim()
5
- return [bin && bin.length > 0 ? bin : 'eas', ...args]
12
+ return [bin && bin.length > 0 ? bin : (localBin('eas') ?? 'eas'), ...args]
6
13
  }
7
14
 
8
- export async function runCaptured(command: string[]): Promise<string> {
9
- const child = Bun.spawn(command, {
10
- stdout: 'pipe',
11
- stderr: 'pipe'
12
- })
13
- const [stdout, stderr, exitCode] = await Promise.all([
14
- new Response(child.stdout).text(),
15
- new Response(child.stderr).text(),
16
- child.exited
15
+ const localBins = new Map<string, string | null>()
16
+
17
+ /** `node_modules/.bin/<name>` from the working directory up to the repository root. */
18
+ export function localBin(name: string): string | null {
19
+ const cwd = process.cwd()
20
+ const cacheKey = `${cwd}\0${name}`
21
+ const cached = localBins.get(cacheKey)
22
+ if (cached !== undefined) return cached
23
+ let bin: string | null = null
24
+ for (let dir = cwd; ; dir = dirname(dir)) {
25
+ const candidate = join(dir, 'node_modules', '.bin', name)
26
+ if (existsSync(candidate)) {
27
+ bin = candidate
28
+ break
29
+ }
30
+ if (existsSync(join(dir, '.git')) || dirname(dir) === dir) break
31
+ }
32
+ localBins.set(cacheKey, bin)
33
+ return bin
34
+ }
35
+
36
+ /** A failed command. `tail` holds its last non-empty output lines. */
37
+ export class CommandError extends Error {
38
+ constructor(
39
+ message: string,
40
+ readonly tail: string[]
41
+ ) {
42
+ super(message)
43
+ this.name = 'CommandError'
44
+ }
45
+ }
46
+
47
+ const ERROR_TAIL_LINES = 20
48
+
49
+ function lastLines(output: string, count = ERROR_TAIL_LINES): string[] {
50
+ return output
51
+ .split('\n')
52
+ .map((line) => line.trimEnd())
53
+ .filter((line) => line.trim().length > 0)
54
+ .slice(-count)
55
+ }
56
+
57
+ // After the command exits, a background process it started can keep the
58
+ // pipes open; stop reading after this long instead of waiting for it.
59
+ const DRAIN_AFTER_EXIT_MS = 1000
60
+
61
+ function collect(
62
+ stream: ReadableStream<Uint8Array>,
63
+ echo: NodeJS.WriteStream | null,
64
+ sink: string[]
65
+ ) {
66
+ const reader = stream.getReader()
67
+ const decoder = new TextDecoder()
68
+ let text = ''
69
+ const done = (async () => {
70
+ try {
71
+ for (;;) {
72
+ const { value, done } = await reader.read()
73
+ if (done) break
74
+ echo?.write(value)
75
+ const part = decoder.decode(value, { stream: true })
76
+ text += part
77
+ sink.push(part)
78
+ }
79
+ } catch {}
80
+ const rest = decoder.decode()
81
+ sink.push(rest)
82
+ text += rest
83
+ })()
84
+ return {
85
+ done,
86
+ stop: () => reader.cancel().catch(() => undefined),
87
+ text: () => text
88
+ }
89
+ }
90
+
91
+ async function spawn(
92
+ command: string[],
93
+ echoStdout: boolean,
94
+ echoStderr = true
95
+ ) {
96
+ const child = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' })
97
+ // Both streams in arrival order, for the failure tail.
98
+ const combined: string[] = []
99
+ const out = collect(
100
+ child.stdout,
101
+ echoStdout ? process.stdout : null,
102
+ combined
103
+ )
104
+ // stderr reaches the log: EAS prints its warnings and progress there.
105
+ const err = collect(
106
+ child.stderr,
107
+ echoStderr ? process.stderr : null,
108
+ combined
109
+ )
110
+ const exitCode = await child.exited
111
+ const drained = Promise.all([out.done, err.done])
112
+ let timer: ReturnType<typeof setTimeout> | undefined
113
+ const timedOut = await Promise.race([
114
+ drained.then(() => false),
115
+ new Promise<boolean>((resolve) => {
116
+ timer = setTimeout(() => resolve(true), DRAIN_AFTER_EXIT_MS)
117
+ })
17
118
  ])
119
+ clearTimeout(timer)
120
+ if (timedOut) {
121
+ await Promise.all([out.stop(), err.stop()])
122
+ await drained
123
+ }
124
+ return {
125
+ stdout: out.text(),
126
+ stderr: err.text(),
127
+ exitCode,
128
+ tail: lastLines(combined.join(''))
129
+ }
130
+ }
131
+
132
+ /** Runs a command for its stdout. stderr streams to the log. */
133
+ export async function runCaptured(command: string[]): Promise<string> {
134
+ const { stdout, stderr, exitCode, tail } = await spawn(command, false)
18
135
  if (exitCode !== 0) {
19
- throw new Error(describeFailure(command, exitCode, { stderr, stdout }))
136
+ throw new CommandError(
137
+ describeFailure(command, exitCode, { stderr, stdout }),
138
+ tail
139
+ )
140
+ }
141
+ return stdout
142
+ }
143
+
144
+ /** Like runCaptured, but prints nothing: for checks that quote the error. */
145
+ export async function runSilent(command: string[]): Promise<string> {
146
+ const { stdout, stderr, exitCode, tail } = await spawn(command, false, false)
147
+ if (exitCode !== 0) {
148
+ throw new CommandError(
149
+ describeFailure(command, exitCode, { stderr, stdout }),
150
+ tail
151
+ )
152
+ }
153
+ return stdout
154
+ }
155
+
156
+ /** Runs a command with its output in the log. Resolves its stdout. */
157
+ export async function runInherited(command: string[]): Promise<string> {
158
+ const { stdout, exitCode, tail } = await spawn(command, true)
159
+ if (exitCode !== 0) {
160
+ throw new CommandError(
161
+ `Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`,
162
+ tail
163
+ )
20
164
  }
21
165
  return stdout
22
166
  }
@@ -50,23 +194,37 @@ export function describeFailure(
50
194
  return sections.join('\n')
51
195
  }
52
196
 
53
- export async function runInherited(command: string[]): Promise<string> {
54
- const child = Bun.spawn(command, { stdout: 'inherit', stderr: 'inherit' })
55
- const exitCode = await child.exited
56
- if (exitCode !== 0) {
57
- throw new Error(
58
- `Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`
59
- )
197
+ /**
198
+ * Parses the last JSON document in `output`. `preamble` is whatever came
199
+ * before it: eas-cli prints warnings to stdout before it switches to JSON-only
200
+ * output, for example when `npx expo config` failed and it fell back.
201
+ */
202
+ export function parseJsonDocument(output: string): {
203
+ value: unknown
204
+ preamble: string
205
+ } {
206
+ // A document starts at the beginning of the output or of a line; pretty
207
+ // printed JSON indents everything after its first line.
208
+ const starts = [0]
209
+ for (const match of output.matchAll(/\n(?=[[{])/g)) {
210
+ starts.push(match.index + 1)
60
211
  }
61
- return ''
212
+ for (const start of starts) {
213
+ try {
214
+ return {
215
+ value: JSON.parse(output.slice(start)) as unknown,
216
+ preamble: output.slice(0, start).trim()
217
+ }
218
+ } catch {}
219
+ }
220
+ throw new Error(`Expected JSON output, received:\n${tail(output)}`)
62
221
  }
63
222
 
223
+ /** Parses JSON output, logging any text printed before it. */
64
224
  export function parseJson(output: string): unknown {
65
- try {
66
- return JSON.parse(output) as unknown
67
- } catch {
68
- throw new Error(`Expected JSON output, received:\n${output}`)
69
- }
225
+ const { value, preamble } = parseJsonDocument(output)
226
+ if (preamble) process.stderr.write(`${preamble}\n`)
227
+ return value
70
228
  }
71
229
 
72
230
  export function getRecord(
@@ -80,7 +238,7 @@ export function getRecord(
80
238
  }
81
239
 
82
240
  export function parseFingerprintHash(output: string): string {
83
- const record = getRecord(parseJson(output), 'EAS fingerprint')
241
+ const record = getRecord(parseJsonDocument(output).value, 'EAS fingerprint')
84
242
  if (typeof record.hash !== 'string' || record.hash.length === 0) {
85
243
  throw new Error('EAS fingerprint output did not contain a full hash')
86
244
  }
@@ -99,21 +257,85 @@ export function dryRunCommand(command: string[]): void {
99
257
  process.stderr.write(`[dry-run] ${quoteCommand(command)}\n`)
100
258
  }
101
259
 
260
+ export type Stage =
261
+ | 'doctor'
262
+ | 'diff'
263
+ | 'fingerprint'
264
+ | 'build list'
265
+ | 'errored build'
266
+ | 'fingerprint diff'
267
+ | 'native build'
268
+ | 'ota export'
269
+ | 'ota update'
270
+ | 'pre-update'
271
+ | 'post-update'
272
+ | 'recovery'
273
+
274
+ /** A failed release step: what ran, and the last lines it printed. */
275
+ export class StageError extends Error {
276
+ constructor(
277
+ readonly stage: Stage,
278
+ message: string,
279
+ readonly tail: string[] = []
280
+ ) {
281
+ super(message)
282
+ this.name = 'StageError'
283
+ }
284
+ }
285
+
286
+ /** Runs one release step, logs how long it took, and names it on failure. */
287
+ export async function stage<T>(name: Stage, run: () => Promise<T>): Promise<T> {
288
+ const started = performance.now()
289
+ try {
290
+ return await run()
291
+ } catch (error) {
292
+ if (error instanceof StageError) throw error
293
+ throw new StageError(
294
+ name,
295
+ error instanceof Error ? error.message : String(error),
296
+ error instanceof CommandError ? error.tail : []
297
+ )
298
+ } finally {
299
+ const seconds = ((performance.now() - started) / 1000).toFixed(1)
300
+ process.stderr.write(`[expo-release] ${name}: ${seconds}s\n`)
301
+ }
302
+ }
303
+
304
+ /**
305
+ * The fingerprint fails when eas-cli printed anything before its JSON: that
306
+ * means the app config did not load and eas-cli fell back to its bundled
307
+ * `@expo/config`, whose fingerprint may not be the one EAS builds with.
308
+ * `explainConfig` runs `expo config` to surface the real error.
309
+ */
102
310
  export async function generateFingerprint(
103
311
  input: { platform: string; profile: string },
104
- commandRunner: CommandRunner
312
+ commandRunner: CommandRunner,
313
+ explainConfig?: () => Promise<string>
105
314
  ): Promise<string> {
106
- return parseFingerprintHash(
107
- await commandRunner(
108
- easCommand(
109
- 'fingerprint:generate',
110
- '--platform',
111
- input.platform,
112
- '--build-profile',
113
- input.profile,
114
- '--json',
115
- '--non-interactive'
116
- )
315
+ const output = await commandRunner(
316
+ easCommand(
317
+ 'fingerprint:generate',
318
+ '--platform',
319
+ input.platform,
320
+ '--build-profile',
321
+ input.profile,
322
+ '--json',
323
+ '--non-interactive'
117
324
  )
118
325
  )
326
+ const { preamble } = parseJsonDocument(output)
327
+ if (preamble) {
328
+ const reason = explainConfig ? await explainConfig() : ''
329
+ throw new CommandError(
330
+ [
331
+ 'eas fingerprint:generate could not load the app config and fell back to a default one, so its fingerprint may not match what EAS builds.',
332
+ `eas-cli printed:\n${preamble}`,
333
+ reason && `expo config said:\n${reason}`
334
+ ]
335
+ .filter(Boolean)
336
+ .join('\n'),
337
+ lastLines(reason || preamble)
338
+ )
339
+ }
340
+ return parseFingerprintHash(output)
119
341
  }
@@ -5,6 +5,8 @@ export {
5
5
  } from './error-boundary'
6
6
  export {
7
7
  checkForOTAUpdate,
8
+ getReleaseInfo,
9
+ type ReleaseInfo,
8
10
  type ReloadApp,
9
11
  type UseOTAUpdatesOptions,
10
12
  useOTAUpdates
@@ -1,11 +1,44 @@
1
1
  import * as Updates from 'expo-updates'
2
2
  import { useEffect, useRef } from 'react'
3
+ import { AppState } from 'react-native'
3
4
 
4
5
  declare const __DEV__: boolean | undefined
5
6
 
6
7
  /** Restarts the app on the downloaded update. */
7
8
  export type ReloadApp = () => Promise<void>
8
9
 
10
+ /** Which release is running, for crash reports and a version screen. */
11
+ export interface ReleaseInfo {
12
+ /** The running EAS update; null for the build's embedded bundle and in Expo Go. */
13
+ updateId: string | null
14
+ runtimeVersion: string | null
15
+ channel: string | null
16
+ /** When the running update (or the embedded bundle) was published. */
17
+ createdAt: Date | null
18
+ isEmbeddedLaunch: boolean
19
+ }
20
+
21
+ /** The running release, read from expo-updates. Safe in Expo Go and development. */
22
+ export function getReleaseInfo(): ReleaseInfo {
23
+ return {
24
+ updateId: Updates.updateId ?? null,
25
+ runtimeVersion: Updates.runtimeVersion ?? null,
26
+ channel: Updates.channel ?? null,
27
+ createdAt: Updates.createdAt ?? null,
28
+ isEmbeddedLaunch: Updates.isEmbeddedLaunch === true
29
+ }
30
+ }
31
+
32
+ /** Downloads an available update; resolves its id (or '') when it is new. */
33
+ async function downloadUpdate(): Promise<string | null> {
34
+ const update = await Updates.checkForUpdateAsync()
35
+ if (!update.isAvailable) return null
36
+ const result = await Updates.fetchUpdateAsync()
37
+ if (!result.isNew) return null
38
+ const id = (result.manifest as { id?: unknown } | undefined)?.id
39
+ return typeof id === 'string' ? id : ''
40
+ }
41
+
9
42
  /**
10
43
  * Checks for an EAS update and downloads it. Calls `onUpdateReady` once a new
11
44
  * update is on disk; show a "Restart" prompt there and call `reload` to apply.
@@ -14,10 +47,7 @@ export type ReloadApp = () => Promise<void>
14
47
  export async function checkForOTAUpdate(
15
48
  onUpdateReady: (reload: ReloadApp) => void
16
49
  ): Promise<boolean> {
17
- const update = await Updates.checkForUpdateAsync()
18
- if (!update.isAvailable) return false
19
- const result = await Updates.fetchUpdateAsync()
20
- if (!result.isNew) return false
50
+ if ((await downloadUpdate()) === null) return false
21
51
  onUpdateReady(() => Updates.reloadAsync())
22
52
  return true
23
53
  }
@@ -25,30 +55,77 @@ export async function checkForOTAUpdate(
25
55
  export interface UseOTAUpdatesOptions {
26
56
  /** Defaults to off in development and wherever expo-updates is disabled (Expo Go). */
27
57
  enabled?: boolean
28
- /** A failed check is retried on the next launch; defaults to ignoring it. */
58
+ /**
59
+ * Check again when the app returns to the foreground, at most once per this
60
+ * many minutes. Off by default: an app kept open for days then gets a fix
61
+ * only after the OS kills it.
62
+ */
63
+ recheckOnForeground?: number
64
+ /** A failed check is retried on the next launch or foreground. Defaults to console.warn. */
29
65
  onError?: (error: unknown) => void
30
66
  }
31
67
 
32
68
  /**
33
- * Checks for an update once per mount. The callbacks are read through refs, so
34
- * an unstable toast function never re-runs the check or stacks prompts.
69
+ * Checks for an update once per mount, and on foreground with
70
+ * `recheckOnForeground`. `onUpdateReady` runs once per downloaded update. The
71
+ * callbacks are read through refs, so an unstable toast function never re-runs
72
+ * the check or stacks prompts.
35
73
  */
36
74
  export function useOTAUpdates(
37
75
  onUpdateReady: (reload: ReloadApp) => void,
38
76
  options: UseOTAUpdatesOptions = {}
39
77
  ): void {
40
78
  const {
41
- enabled = Updates.isEnabled && !(typeof __DEV__ !== 'undefined' && __DEV__)
79
+ enabled = Updates.isEnabled && !(typeof __DEV__ !== 'undefined' && __DEV__),
80
+ recheckOnForeground
42
81
  } = options
43
82
  const callbacks = useRef({ onUpdateReady, onError: options.onError })
44
83
  callbacks.current = { onUpdateReady, onError: options.onError }
45
- const checked = useRef(false)
84
+ const state = useRef({
85
+ checked: false,
86
+ running: false,
87
+ lastCheck: 0,
88
+ readyId: null as string | null
89
+ })
46
90
 
47
91
  useEffect(() => {
48
- if (!enabled || checked.current) return
49
- checked.current = true
50
- checkForOTAUpdate((reload) =>
51
- callbacks.current.onUpdateReady(reload)
52
- ).catch((error: unknown) => callbacks.current.onError?.(error))
53
- }, [enabled])
92
+ if (!enabled) return
93
+ const check = () => {
94
+ const current = state.current
95
+ if (current.running) return
96
+ current.running = true
97
+ current.lastCheck = Date.now()
98
+ downloadUpdate()
99
+ .then((id) => {
100
+ // '' (no manifest id) always counts as a new update.
101
+ if (id === null || (id !== '' && id === current.readyId)) return
102
+ current.readyId = id
103
+ callbacks.current.onUpdateReady(() => Updates.reloadAsync())
104
+ })
105
+ .catch((error: unknown) =>
106
+ (
107
+ callbacks.current.onError ??
108
+ ((e: unknown) => console.warn('OTA update check failed', e))
109
+ )(error)
110
+ )
111
+ .finally(() => {
112
+ current.running = false
113
+ })
114
+ }
115
+
116
+ if (!state.current.checked) {
117
+ state.current.checked = true
118
+ check()
119
+ }
120
+ if (recheckOnForeground === undefined) return
121
+ const subscription = AppState.addEventListener('change', (next) => {
122
+ if (
123
+ next === 'active' &&
124
+ Date.now() - state.current.lastCheck >= recheckOnForeground * 60_000
125
+ ) {
126
+ check()
127
+ }
128
+ })
129
+ return () => subscription.remove()
130
+ }, [enabled, recheckOnForeground])
54
131
  }
@@ -23,7 +23,10 @@ export interface PushNotificationsOptions {
23
23
  onError?: (message: string, error: unknown) => void
24
24
  /** Defaults to what expo-notifications reads from the app config. */
25
25
  projectId?: string
26
- /** Android channel `default`, needed for heads-up notifications. */
26
+ /**
27
+ * Android channel `default`, needed for heads-up notifications. Call
28
+ * `setAndroidChannel` again when its texts depend on the locale.
29
+ */
27
30
  androidChannel?: Notifications.NotificationChannelInput
28
31
  /** How notifications show while the app is in the foreground. */
29
32
  foregroundBehavior?: Notifications.NotificationBehavior
@@ -34,7 +37,10 @@ export interface PushNotificationsOptions {
34
37
 
35
38
  export interface NotificationListeners {
36
39
  onReceived?: (notification: Notifications.Notification) => void
37
- /** Also called with the response that launched the app, if any. */
40
+ /**
41
+ * Also called with the response that launched the app, if any. Each
42
+ * response is delivered once per app start, however often `listen` runs.
43
+ */
38
44
  onResponse?: (response: Notifications.NotificationResponse) => void
39
45
  }
40
46
 
@@ -66,18 +72,29 @@ export function createPushNotifications(options: PushNotificationsOptions) {
66
72
  }
67
73
  })
68
74
  // Channels exist only on Android; elsewhere this resolves to null.
69
- Notifications.setNotificationChannelAsync(
70
- 'default',
71
- options.androidChannel ?? {
75
+ function setAndroidChannel(
76
+ channel: Notifications.NotificationChannelInput = {
72
77
  name: 'Default',
73
78
  importance: Notifications.AndroidImportance.HIGH,
74
79
  vibrationPattern: [0, 250, 250, 250]
75
80
  }
76
- ).catch((error: unknown) =>
77
- onError('Failed to create the notification channel', error)
78
- )
81
+ ) {
82
+ return Notifications.setNotificationChannelAsync('default', channel)
83
+ .then(() => undefined)
84
+ .catch((error: unknown) =>
85
+ onError('Failed to create the notification channel', error)
86
+ )
87
+ }
88
+ void setAndroidChannel(options.androidChannel)
79
89
 
80
90
  let pendingPrompt: Promise<boolean> | null = null
91
+ // Responses already handed to onResponse. Every getLastNotificationResponse
92
+ // call returns the last one again, so a replay is skipped once delivered;
93
+ // live taps always go through. A repeating notification keeps its
94
+ // identifier, so the date is part of the key.
95
+ const deliveredResponses = new Set<string>()
96
+ const responseKey = (response: Notifications.NotificationResponse) =>
97
+ `${response.notification?.request?.identifier}:${response.notification?.date}:${response.actionIdentifier}`
81
98
 
82
99
  const getPermissionStatus = () => Notifications.getPermissionsAsync()
83
100
 
@@ -181,6 +198,8 @@ export function createPushNotifications(options: PushNotificationsOptions) {
181
198
  requestPermissions,
182
199
  getPushToken,
183
200
  syncToken,
201
+ /** Recreates the Android `default` channel, for example after a locale change. */
202
+ setAndroidChannel,
184
203
 
185
204
  /**
186
205
  * Asks for push once per install (after sign-in, say), then registers the
@@ -205,7 +224,11 @@ export function createPushNotifications(options: PushNotificationsOptions) {
205
224
  return true
206
225
  },
207
226
 
208
- /** Removes this device's token from your backend. */
227
+ /**
228
+ * Removes this device's token from your backend, and stops `listen` from
229
+ * re-registering it. Rejects when `registerDevice(null)` does, so a
230
+ * settings screen can show the failure; catch it on sign-out.
231
+ */
209
232
  async disable() {
210
233
  await registerDevice(null)
211
234
  // KeyValueStorage has no removeItem; an empty token means none was sent.
@@ -222,16 +245,26 @@ export function createPushNotifications(options: PushNotificationsOptions) {
222
245
  onError('Failed to refresh the push token', error)
223
246
  )
224
247
  void refresh()
248
+ const deliver =
249
+ onResponse &&
250
+ ((response: Notifications.NotificationResponse) => {
251
+ deliveredResponses.add(responseKey(response))
252
+ onResponse(response)
253
+ })
225
254
  const subscriptions = [
226
255
  Notifications.addPushTokenListener(refresh),
227
256
  onReceived && Notifications.addNotificationReceivedListener(onReceived),
228
- onResponse &&
229
- Notifications.addNotificationResponseReceivedListener(onResponse)
257
+ deliver &&
258
+ Notifications.addNotificationResponseReceivedListener(deliver)
230
259
  ]
231
- const launchResponse = onResponse
260
+ const launchResponse = deliver
232
261
  ? Notifications.getLastNotificationResponse()
233
262
  : null
234
- if (launchResponse) onResponse?.(launchResponse)
263
+ if (
264
+ launchResponse &&
265
+ !deliveredResponses.has(responseKey(launchResponse))
266
+ )
267
+ deliver?.(launchResponse)
235
268
  return () => {
236
269
  for (const subscription of subscriptions) subscription?.remove()
237
270
  }
@@ -0,0 +1,10 @@
1
+ // The one react-native API the runtime uses. react-native is an optional peer
2
+ // that every Expo app has; this keeps it out of the devDependencies.
3
+ declare module 'react-native' {
4
+ export const AppState: {
5
+ addEventListener(
6
+ type: 'change',
7
+ listener: (state: 'active' | 'background' | 'inactive' | string) => void
8
+ ): { remove(): void }
9
+ }
10
+ }
@@ -1,12 +1,15 @@
1
1
  import { exists } from 'node:fs/promises'
2
2
  import type { ReleasePlatform } from './args'
3
+ import { localBin } from './run'
3
4
 
4
5
  export const SOURCE_MAP_OUTPUT_DIR = 'dist'
5
6
 
7
+ /** `EXPO_BIN`, else the app's own expo CLI, else `bunx expo`. */
6
8
  export function expoCommand(...args: string[]): string[] {
7
9
  const bin = process.env.EXPO_BIN?.trim()
8
10
  if (bin && bin.length > 0) return [bin, ...args]
9
- return ['bunx', 'expo', ...args]
11
+ const local = localBin('expo')
12
+ return local ? [local, ...args] : ['bunx', 'expo', ...args]
10
13
  }
11
14
 
12
15
  export function sourceMapExportCommand(platform: ReleasePlatform): string[] {