expo-release 2.0.0 → 2.2.1

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/cli.ts CHANGED
@@ -1,16 +1,19 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { type CommandContext, defineCli, defineCommand } from 'hanzio/cli'
3
4
  import {
4
- type ReleaseOptions,
5
- USAGE,
5
+ type CommandName,
6
6
  needsDecide,
7
- parseReleaseOptions,
8
- shouldIgnoreExpoImpact
7
+ type ReleaseFlags,
8
+ type ReleaseOptions,
9
+ releaseFlags,
10
+ shouldIgnoreExpoImpact,
11
+ toReleaseOptions
9
12
  } from './args'
10
13
  import {
11
14
  type DecisionInput,
12
- type ReleaseDecision,
13
15
  decideRelease,
16
+ type ReleaseDecision,
14
17
  writeDecideGitHubOutput
15
18
  } from './decide'
16
19
  import {
@@ -49,49 +52,83 @@ function toOperationInput(
49
52
  preUpdate: options.preUpdate,
50
53
  postUpdate: options.postUpdate,
51
54
  updateExtraArgs: options.updateExtraArgs,
55
+ exportSourceMaps: options.exportSourceMaps,
52
56
  dryRun: options.dryRun
53
57
  }
54
58
  }
55
59
 
56
- async function main() {
57
- const raw = Bun.argv.slice(2)
58
- if (raw.length === 0 || raw[0] === '--help' || raw[0] === '-h') {
59
- process.stdout.write(`${USAGE}\n`)
60
- return
61
- }
60
+ type Context = CommandContext<ReleaseFlags>
61
+
62
+ async function decide(options: ReleaseOptions, ctx: Context) {
63
+ const decision = await decideRelease(toDecisionInput(options))
64
+ await writeDecideGitHubOutput(decision)
65
+ ctx.stdout.write(`${JSON.stringify(decision)}\n`)
66
+ return decision
67
+ }
62
68
 
63
- const options = parseReleaseOptions(raw)
69
+ async function operate(
70
+ options: ReleaseOptions,
71
+ decision: ReleaseDecision | null,
72
+ ctx: Context
73
+ ) {
74
+ const result = await operateRelease(toOperationInput(options, decision))
75
+ await writeOperateGitHubOutput(result)
76
+ ctx.stdout.write(`${result}\n`)
77
+ }
64
78
 
65
- switch (options.command) {
66
- case 'decide': {
67
- const decision = await decideRelease(toDecisionInput(options))
68
- await writeDecideGitHubOutput(decision)
69
- process.stdout.write(`${JSON.stringify(decision)}\n`)
70
- return
71
- }
72
- case 'operate': {
73
- const result = await operateRelease(toOperationInput(options, null))
74
- await writeOperateGitHubOutput(result)
75
- process.stdout.write(`${result}\n`)
76
- return
77
- }
78
- case 'release': {
79
- let decision: ReleaseDecision | null = null
80
- if (needsDecide(options.operation)) {
81
- decision = await decideRelease(toDecisionInput(options))
82
- await writeDecideGitHubOutput(decision)
83
- process.stdout.write(`${JSON.stringify(decision)}\n`)
79
+ const COMMANDS: CommandName[] = ['release', 'decide', 'operate']
80
+
81
+ const cli = defineCli({
82
+ name: 'expo-release',
83
+ description:
84
+ 'Fingerprint-driven Expo EAS release: OTA update or native store build',
85
+ // All three commands take the same flags, before or after the command name.
86
+ globals: releaseFlags,
87
+ context: (flags) => flags,
88
+ commands: {
89
+ release: defineCommand({
90
+ description: 'Run decide, then operate on its decision',
91
+ run: async (_input, ctx: Context) => {
92
+ const options = toReleaseOptions('release', ctx)
93
+ const decision = needsDecide(options.operation)
94
+ ? await decide(options, ctx)
95
+ : null
96
+ await operate(options, decision, ctx)
97
+ }
98
+ }),
99
+ decide: defineCommand({
100
+ description:
101
+ 'Print whether this change needs no deploy, an OTA update or a native build',
102
+ run: async (_input, ctx: Context) => {
103
+ await decide(toReleaseOptions('decide', ctx), ctx)
84
104
  }
85
- const result = await operateRelease(toOperationInput(options, decision))
86
- await writeOperateGitHubOutput(result)
87
- process.stdout.write(`${result}\n`)
88
- return
89
- }
90
- default: {
91
- const _exhaustive: never = options.command
92
- throw new Error(`Unsupported command: ${_exhaustive}`)
93
- }
105
+ }),
106
+ operate: defineCommand({
107
+ description: 'Run --operation for --deploy-type',
108
+ run: async (_input, ctx: Context) => {
109
+ await operate(toReleaseOptions('operate', ctx), null, ctx)
110
+ }
111
+ })
94
112
  }
113
+ })
114
+
115
+ // GitHub Actions passes an unset input as an empty string; treat it as unset.
116
+ const env = Object.fromEntries(
117
+ Object.entries(process.env).flatMap(([name, value]) =>
118
+ value?.trim() ? [[name, value.trim()]] : []
119
+ )
120
+ )
121
+
122
+ let argv = process.argv.slice(2)
123
+ if (!argv.some((arg) => COMMANDS.includes(arg as CommandName))) {
124
+ // The composite action names the command in EXPO_RELEASE_COMMAND and passes no argv.
125
+ argv = env.EXPO_RELEASE_COMMAND
126
+ ? [env.EXPO_RELEASE_COMMAND, ...argv]
127
+ : argv.length === 0
128
+ ? ['--help']
129
+ : argv
95
130
  }
96
131
 
97
- await main()
132
+ const code = await cli.run(argv, { env })
133
+ // Bad arguments exit 1, as before hanzio/cli, which uses 2.
134
+ process.exitCode = code === 2 ? 1 : code
package/src/operate.ts CHANGED
@@ -7,6 +7,11 @@ import {
7
7
  generateFingerprint,
8
8
  runInherited
9
9
  } from './run'
10
+ import {
11
+ assertSourceMaps,
12
+ mergeUpdateArgs,
13
+ sourceMapExportCommand
14
+ } from './source-maps'
10
15
 
11
16
  export type { Operation }
12
17
 
@@ -25,8 +30,10 @@ export type OperationInput = {
25
30
  preUpdate?: string
26
31
  postUpdate?: string
27
32
  updateExtraArgs?: string
33
+ exportSourceMaps?: boolean
28
34
  dryRun?: boolean
29
35
  readPackageJson?: () => Promise<unknown>
36
+ hasSourceMaps?: () => Promise<boolean>
30
37
  }
31
38
 
32
39
  export type OperationResult = 'skipped' | 'ota' | 'native-build' | 'recovery'
@@ -107,6 +114,12 @@ async function publishUpdate(
107
114
  commandRunner: CommandRunner
108
115
  ) {
109
116
  await requireEnvironmentIfNeeded(input)
117
+ if (input.exportSourceMaps) {
118
+ await commandRunner(sourceMapExportCommand(input.platform))
119
+ if (input.dryRun !== true) {
120
+ await assertSourceMaps(input.hasSourceMaps)
121
+ }
122
+ }
110
123
  await runHook(input.preUpdate, commandRunner)
111
124
  const command = easCommand(
112
125
  'update',
@@ -121,7 +134,12 @@ async function publishUpdate(
121
134
  if (input.environment) {
122
135
  command.push('--environment', input.environment)
123
136
  }
124
- command.push(...extraUpdateArgs(input.updateExtraArgs))
137
+ command.push(
138
+ ...mergeUpdateArgs(
139
+ extraUpdateArgs(input.updateExtraArgs),
140
+ input.exportSourceMaps === true
141
+ )
142
+ )
125
143
  await commandRunner(command)
126
144
  await runHook(input.postUpdate, commandRunner)
127
145
  }
package/src/run.ts CHANGED
@@ -16,18 +16,47 @@ export async function runCaptured(command: string[]): Promise<string> {
16
16
  child.exited
17
17
  ])
18
18
  if (exitCode !== 0) {
19
- throw new Error(
20
- `Command failed (${command.join(' ')}):\n${stderr || stdout}`
21
- )
19
+ throw new Error(describeFailure(command, exitCode, { stderr, stdout }))
22
20
  }
23
21
  return stdout
24
22
  }
25
23
 
24
+ const OUTPUT_TAIL_LINES = 100
25
+
26
+ function tail(output: string): string {
27
+ const lines = output.trimEnd().split('\n')
28
+ if (lines.length <= OUTPUT_TAIL_LINES) return lines.join('\n')
29
+ const omitted = lines.length - OUTPUT_TAIL_LINES
30
+ return [
31
+ `... (${omitted} earlier lines omitted)`,
32
+ ...lines.slice(-OUTPUT_TAIL_LINES)
33
+ ].join('\n')
34
+ }
35
+
36
+ // Both streams are kept: EAS can print only an upgrade notice on stderr while
37
+ // the real error lands on stdout (or the reverse). The command line holds no
38
+ // secrets; tokens reach EAS through the environment, which is never printed.
39
+ export function describeFailure(
40
+ command: string[],
41
+ exitCode: number,
42
+ output: { stderr: string; stdout: string }
43
+ ): string {
44
+ const sections = [
45
+ `Command failed with exit code ${exitCode}: ${quoteCommand(command)}`
46
+ ]
47
+ if (output.stderr.trim()) sections.push(`stderr:\n${tail(output.stderr)}`)
48
+ if (output.stdout.trim()) sections.push(`stdout:\n${tail(output.stdout)}`)
49
+ if (sections.length === 1) sections.push('(no output)')
50
+ return sections.join('\n')
51
+ }
52
+
26
53
  export async function runInherited(command: string[]): Promise<string> {
27
54
  const child = Bun.spawn(command, { stdout: 'inherit', stderr: 'inherit' })
28
55
  const exitCode = await child.exited
29
56
  if (exitCode !== 0) {
30
- throw new Error(`Command failed (${command.join(' ')})`)
57
+ throw new Error(
58
+ `Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`
59
+ )
31
60
  }
32
61
  return ''
33
62
  }
@@ -0,0 +1,51 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from 'react'
2
+
3
+ export interface ErrorBoundaryFallbackProps {
4
+ error: Error
5
+ /** Clears the error and renders the children again. */
6
+ reset: () => void
7
+ }
8
+
9
+ export interface ErrorBoundaryProps {
10
+ children?: ReactNode
11
+ fallback: (props: ErrorBoundaryFallbackProps) => ReactNode
12
+ /**
13
+ * Report the error here. Render errors never reach global handlers or
14
+ * error-tracking autocapture, so capture them explicitly.
15
+ */
16
+ onError?: (error: Error, info: ErrorInfo) => void
17
+ }
18
+
19
+ interface ErrorBoundaryState {
20
+ error: Error | null
21
+ }
22
+
23
+ const toError = (value: unknown) =>
24
+ value instanceof Error ? value : new Error(String(value))
25
+
26
+ /** Error boundary without UI: `fallback` renders the error screen. */
27
+ export class ErrorBoundary extends Component<
28
+ ErrorBoundaryProps,
29
+ ErrorBoundaryState
30
+ > {
31
+ override state: ErrorBoundaryState = { error: null }
32
+
33
+ static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
34
+ return { error: toError(error) }
35
+ }
36
+
37
+ override componentDidCatch(error: unknown, info: ErrorInfo) {
38
+ this.props.onError?.(toError(error), info)
39
+ }
40
+
41
+ reset = () => {
42
+ this.setState({ error: null })
43
+ }
44
+
45
+ override render() {
46
+ const { error } = this.state
47
+ return error
48
+ ? this.props.fallback({ error, reset: this.reset })
49
+ : this.props.children
50
+ }
51
+ }
@@ -0,0 +1,19 @@
1
+ export {
2
+ ErrorBoundary,
3
+ type ErrorBoundaryFallbackProps,
4
+ type ErrorBoundaryProps
5
+ } from './error-boundary'
6
+ export {
7
+ checkForOTAUpdate,
8
+ type ReloadApp,
9
+ type UseOTAUpdatesOptions,
10
+ useOTAUpdates
11
+ } from './ota'
12
+ export {
13
+ createPushNotifications,
14
+ type KeyValueStorage,
15
+ type NotificationListeners,
16
+ type PushNotifications,
17
+ type PushNotificationsOptions,
18
+ usePushNotifications
19
+ } from './push'
@@ -0,0 +1,54 @@
1
+ import * as Updates from 'expo-updates'
2
+ import { useEffect, useRef } from 'react'
3
+
4
+ declare const __DEV__: boolean | undefined
5
+
6
+ /** Restarts the app on the downloaded update. */
7
+ export type ReloadApp = () => Promise<void>
8
+
9
+ /**
10
+ * Checks for an EAS update and downloads it. Calls `onUpdateReady` once a new
11
+ * update is on disk; show a "Restart" prompt there and call `reload` to apply.
12
+ * Returns whether a new update was downloaded.
13
+ */
14
+ export async function checkForOTAUpdate(
15
+ onUpdateReady: (reload: ReloadApp) => void
16
+ ): 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
21
+ onUpdateReady(() => Updates.reloadAsync())
22
+ return true
23
+ }
24
+
25
+ export interface UseOTAUpdatesOptions {
26
+ /** Defaults to off in development and wherever expo-updates is disabled (Expo Go). */
27
+ enabled?: boolean
28
+ /** A failed check is retried on the next launch; defaults to ignoring it. */
29
+ onError?: (error: unknown) => void
30
+ }
31
+
32
+ /**
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.
35
+ */
36
+ export function useOTAUpdates(
37
+ onUpdateReady: (reload: ReloadApp) => void,
38
+ options: UseOTAUpdatesOptions = {}
39
+ ): void {
40
+ const {
41
+ enabled = Updates.isEnabled && !(typeof __DEV__ !== 'undefined' && __DEV__)
42
+ } = options
43
+ const callbacks = useRef({ onUpdateReady, onError: options.onError })
44
+ callbacks.current = { onUpdateReady, onError: options.onError }
45
+ const checked = useRef(false)
46
+
47
+ 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])
54
+ }
@@ -0,0 +1,243 @@
1
+ import * as Device from 'expo-device'
2
+ import * as Notifications from 'expo-notifications'
3
+ import { useCallback, useEffect, useState } from 'react'
4
+
5
+ /** Async key-value store. AsyncStorage and expo-secure-store adapters fit. */
6
+ export interface KeyValueStorage {
7
+ getItem(key: string): Promise<string | null>
8
+ setItem(key: string, value: string): Promise<void>
9
+ }
10
+
11
+ export interface PushNotificationsOptions {
12
+ /** Remembers that the one-time prompt ran. */
13
+ storage: KeyValueStorage
14
+ /** Saves this device's Expo push token on your backend; `null` removes it. */
15
+ registerDevice: (token: string | null) => Promise<void>
16
+ /**
17
+ * Explains push before the OS dialog. Resolve `true` to continue. Without
18
+ * it, `prompt()` goes straight to the OS dialog.
19
+ */
20
+ confirm?: () => Promise<boolean>
21
+ /** Push is off: the app runs on a simulator or the user denied permission. */
22
+ onUnavailable?: (reason: 'not-a-device' | 'denied') => void
23
+ onError?: (message: string, error: unknown) => void
24
+ /** Defaults to what expo-notifications reads from the app config. */
25
+ projectId?: string
26
+ /** Android channel `default`, needed for heads-up notifications. */
27
+ androidChannel?: Notifications.NotificationChannelInput
28
+ /** How notifications show while the app is in the foreground. */
29
+ foregroundBehavior?: Notifications.NotificationBehavior
30
+ storageKey?: string
31
+ }
32
+
33
+ export interface NotificationListeners {
34
+ onReceived?: (notification: Notifications.Notification) => void
35
+ /** Also called with the response that launched the app, if any. */
36
+ onResponse?: (response: Notifications.NotificationResponse) => void
37
+ }
38
+
39
+ export type PushNotifications = ReturnType<typeof createPushNotifications>
40
+
41
+ /**
42
+ * expo-notifications behind one object: permission flow, Expo push token and
43
+ * device registration. Create it once at module level.
44
+ */
45
+ export function createPushNotifications(options: PushNotificationsOptions) {
46
+ const {
47
+ storage,
48
+ registerDevice,
49
+ confirm,
50
+ onUnavailable,
51
+ onError = (message, error) => console.error(message, error),
52
+ projectId,
53
+ storageKey = '@notifications_permission_asked'
54
+ } = options
55
+
56
+ Notifications.setNotificationHandler({
57
+ handleNotification: async () =>
58
+ options.foregroundBehavior ?? {
59
+ shouldShowBanner: true,
60
+ shouldShowList: true,
61
+ shouldPlaySound: true,
62
+ shouldSetBadge: false
63
+ }
64
+ })
65
+ // Channels exist only on Android; elsewhere this resolves to null.
66
+ Notifications.setNotificationChannelAsync(
67
+ 'default',
68
+ options.androidChannel ?? {
69
+ name: 'Default',
70
+ importance: Notifications.AndroidImportance.HIGH,
71
+ vibrationPattern: [0, 250, 250, 250]
72
+ }
73
+ ).catch((error: unknown) =>
74
+ onError('Failed to create the notification channel', error)
75
+ )
76
+
77
+ let pendingPrompt: Promise<boolean> | null = null
78
+
79
+ const getPermissionStatus = () => Notifications.getPermissionsAsync()
80
+
81
+ async function hasPrompted() {
82
+ try {
83
+ return (await storage.getItem(storageKey)) === 'true'
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ /** Shows the OS dialog when it still can. Resolves whether push is allowed. */
90
+ async function requestPermissions() {
91
+ if (!Device.isDevice) {
92
+ onUnavailable?.('not-a-device')
93
+ return false
94
+ }
95
+ try {
96
+ const { granted } = await Notifications.requestPermissionsAsync()
97
+ if (!granted) onUnavailable?.('denied')
98
+ return granted
99
+ } catch (error) {
100
+ onError('Failed to request notification permissions', error)
101
+ return false
102
+ }
103
+ }
104
+
105
+ async function getPushToken() {
106
+ if (!Device.isDevice) return null
107
+ try {
108
+ const token = await Notifications.getExpoPushTokenAsync(
109
+ projectId ? { projectId } : undefined
110
+ )
111
+ return token.data
112
+ } catch (error) {
113
+ onError('Failed to get the Expo push token', error)
114
+ return null
115
+ }
116
+ }
117
+
118
+ /** Registers the current token. Resolves whether one was registered. */
119
+ async function syncToken() {
120
+ if (!(await getPermissionStatus()).granted) return false
121
+ const token = await getPushToken()
122
+ if (!token) return false
123
+ try {
124
+ await registerDevice(token)
125
+ return true
126
+ } catch (error) {
127
+ onError('Failed to register the push token', error)
128
+ return false
129
+ }
130
+ }
131
+
132
+ async function runPrompt() {
133
+ if (await hasPrompted()) return false
134
+ // Mark first so a crash or a second caller never shows the dialog twice.
135
+ try {
136
+ await storage.setItem(storageKey, 'true')
137
+ } catch (error) {
138
+ onError('Failed to remember the notification prompt', error)
139
+ }
140
+ if (confirm && !(await confirm())) return false
141
+ if (!(await requestPermissions())) return false
142
+ await syncToken()
143
+ return true
144
+ }
145
+
146
+ return {
147
+ getPermissionStatus,
148
+ hasPrompted,
149
+ requestPermissions,
150
+ getPushToken,
151
+ syncToken,
152
+
153
+ /**
154
+ * Asks for push once per install (after sign-in, say), then registers the
155
+ * token. Never throws; concurrent calls share one dialog.
156
+ */
157
+ prompt(): Promise<boolean> {
158
+ pendingPrompt ??= runPrompt()
159
+ .catch((error: unknown) => {
160
+ onError('Failed to prompt for notifications', error)
161
+ return false
162
+ })
163
+ .finally(() => {
164
+ pendingPrompt = null
165
+ })
166
+ return pendingPrompt
167
+ },
168
+
169
+ /** Turns push on from settings. Resolves whether it is on. */
170
+ async enable() {
171
+ if (!(await requestPermissions())) return false
172
+ await syncToken()
173
+ return true
174
+ },
175
+
176
+ /** Removes this device's token from your backend. */
177
+ disable() {
178
+ return registerDevice(null)
179
+ },
180
+
181
+ /** Subscribes to notifications. Returns the unsubscribe function. */
182
+ listen({ onReceived, onResponse }: NotificationListeners) {
183
+ const subscriptions = [
184
+ onReceived && Notifications.addNotificationReceivedListener(onReceived),
185
+ onResponse &&
186
+ Notifications.addNotificationResponseReceivedListener(onResponse)
187
+ ]
188
+ const launchResponse = onResponse
189
+ ? Notifications.getLastNotificationResponse()
190
+ : null
191
+ if (launchResponse) onResponse?.(launchResponse)
192
+ return () => {
193
+ for (const subscription of subscriptions) subscription?.remove()
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Push state for a settings screen. `enabled` is the user's saved preference;
201
+ * while it is on, the token is re-registered on mount.
202
+ */
203
+ export function usePushNotifications(
204
+ push: PushNotifications,
205
+ { enabled = false }: { enabled?: boolean } = {}
206
+ ) {
207
+ const [permission, setPermission] =
208
+ useState<Notifications.NotificationPermissionsStatus | null>(null)
209
+ const [isLoading, setIsLoading] = useState(false)
210
+
211
+ const refresh = useCallback(async () => {
212
+ setPermission(await push.getPermissionStatus())
213
+ }, [push])
214
+
215
+ useEffect(() => {
216
+ void refresh()
217
+ if (enabled) void push.syncToken()
218
+ }, [push, enabled, refresh])
219
+
220
+ const withLoading = useCallback(
221
+ <T>(action: () => Promise<T>) =>
222
+ async () => {
223
+ setIsLoading(true)
224
+ try {
225
+ return await action()
226
+ } finally {
227
+ await refresh()
228
+ setIsLoading(false)
229
+ }
230
+ },
231
+ [refresh]
232
+ )
233
+
234
+ return {
235
+ permission,
236
+ isEnabled: enabled && permission?.granted === true,
237
+ isLoading,
238
+ prompt: withLoading(push.prompt),
239
+ enable: withLoading(push.enable),
240
+ disable: withLoading(push.disable),
241
+ refresh
242
+ }
243
+ }
@@ -0,0 +1,61 @@
1
+ import { exists } from 'node:fs/promises'
2
+ import type { ReleasePlatform } from './args'
3
+
4
+ export const SOURCE_MAP_OUTPUT_DIR = 'dist'
5
+
6
+ export function expoCommand(...args: string[]): string[] {
7
+ const bin = process.env.EXPO_BIN?.trim()
8
+ if (bin && bin.length > 0) return [bin, ...args]
9
+ return ['bunx', 'expo', ...args]
10
+ }
11
+
12
+ export function sourceMapExportCommand(platform: ReleasePlatform): string[] {
13
+ return expoCommand(
14
+ 'export',
15
+ '--platform',
16
+ platform,
17
+ '--output-dir',
18
+ SOURCE_MAP_OUTPUT_DIR,
19
+ '--source-maps',
20
+ 'external'
21
+ )
22
+ }
23
+
24
+ export function sourceMapUpdateArgs(): string[] {
25
+ return ['--input-dir', SOURCE_MAP_OUTPUT_DIR, '--skip-bundler']
26
+ }
27
+
28
+ export function mergeUpdateArgs(
29
+ extra: string[] | undefined,
30
+ exportSourceMaps: boolean
31
+ ): string[] {
32
+ const args = extra ? [...extra] : []
33
+ if (!exportSourceMaps) return args
34
+ if (!args.includes('--input-dir')) {
35
+ args.push('--input-dir', SOURCE_MAP_OUTPUT_DIR)
36
+ }
37
+ if (!args.includes('--skip-bundler')) {
38
+ args.push('--skip-bundler')
39
+ }
40
+ return args
41
+ }
42
+
43
+ export async function defaultHasSourceMaps(
44
+ directory = SOURCE_MAP_OUTPUT_DIR
45
+ ): Promise<boolean> {
46
+ if (!(await exists(directory))) return false
47
+
48
+ for await (const _ of new Bun.Glob('**/*.map').scan(directory)) {
49
+ return true
50
+ }
51
+ return false
52
+ }
53
+
54
+ export async function assertSourceMaps(
55
+ hasSourceMaps: () => Promise<boolean> = defaultHasSourceMaps
56
+ ) {
57
+ if (await hasSourceMaps()) return
58
+ throw new Error(
59
+ 'Expo export did not produce Hermes source maps in dist; refusing to publish an OTA update.'
60
+ )
61
+ }