expo-release 2.0.0 → 2.2.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/dist/operate.d.ts CHANGED
@@ -16,8 +16,10 @@ export type OperationInput = {
16
16
  preUpdate?: string;
17
17
  postUpdate?: string;
18
18
  updateExtraArgs?: string;
19
+ exportSourceMaps?: boolean;
19
20
  dryRun?: boolean;
20
21
  readPackageJson?: () => Promise<unknown>;
22
+ hasSourceMaps?: () => Promise<boolean>;
21
23
  };
22
24
  export type OperationResult = 'skipped' | 'ota' | 'native-build' | 'recovery';
23
25
  export declare function updateMessage(input: {
package/dist/run.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export type CommandRunner = (command: string[]) => Promise<string>;
2
2
  export declare function easCommand(...args: string[]): string[];
3
3
  export declare function runCaptured(command: string[]): Promise<string>;
4
+ export declare function describeFailure(command: string[], exitCode: number, output: {
5
+ stderr: string;
6
+ stdout: string;
7
+ }): string;
4
8
  export declare function runInherited(command: string[]): Promise<string>;
5
9
  export declare function parseJson(output: string): unknown;
6
10
  export declare function getRecord(value: unknown, context: string): Record<string, unknown>;
@@ -0,0 +1,27 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
2
+ export interface ErrorBoundaryFallbackProps {
3
+ error: Error;
4
+ /** Clears the error and renders the children again. */
5
+ reset: () => void;
6
+ }
7
+ export interface ErrorBoundaryProps {
8
+ children?: ReactNode;
9
+ fallback: (props: ErrorBoundaryFallbackProps) => ReactNode;
10
+ /**
11
+ * Report the error here. Render errors never reach global handlers or
12
+ * error-tracking autocapture, so capture them explicitly.
13
+ */
14
+ onError?: (error: Error, info: ErrorInfo) => void;
15
+ }
16
+ interface ErrorBoundaryState {
17
+ error: Error | null;
18
+ }
19
+ /** Error boundary without UI: `fallback` renders the error screen. */
20
+ export declare class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
21
+ state: ErrorBoundaryState;
22
+ static getDerivedStateFromError(error: unknown): ErrorBoundaryState;
23
+ componentDidCatch(error: unknown, info: ErrorInfo): void;
24
+ reset: () => void;
25
+ render(): ReactNode;
26
+ }
27
+ export {};
@@ -0,0 +1,3 @@
1
+ export { ErrorBoundary, type ErrorBoundaryFallbackProps, type ErrorBoundaryProps } from './error-boundary';
2
+ export { checkForOTAUpdate, type ReloadApp, type UseOTAUpdatesOptions, useOTAUpdates } from './ota';
3
+ export { createPushNotifications, type KeyValueStorage, type NotificationListeners, type PushNotifications, type PushNotificationsOptions, usePushNotifications } from './push';
@@ -0,0 +1,214 @@
1
+ // src/runtime/error-boundary.ts
2
+ import { Component } from "react";
3
+ var toError = (value) => value instanceof Error ? value : new Error(String(value));
4
+
5
+ class ErrorBoundary extends Component {
6
+ state = { error: null };
7
+ static getDerivedStateFromError(error) {
8
+ return { error: toError(error) };
9
+ }
10
+ componentDidCatch(error, info) {
11
+ this.props.onError?.(toError(error), info);
12
+ }
13
+ reset = () => {
14
+ this.setState({ error: null });
15
+ };
16
+ render() {
17
+ const { error } = this.state;
18
+ return error ? this.props.fallback({ error, reset: this.reset }) : this.props.children;
19
+ }
20
+ }
21
+ // src/runtime/ota.ts
22
+ import * as Updates from "expo-updates";
23
+ import { useEffect, useRef } from "react";
24
+ async function checkForOTAUpdate(onUpdateReady) {
25
+ const update = await Updates.checkForUpdateAsync();
26
+ if (!update.isAvailable)
27
+ return false;
28
+ const result = await Updates.fetchUpdateAsync();
29
+ if (!result.isNew)
30
+ return false;
31
+ onUpdateReady(() => Updates.reloadAsync());
32
+ return true;
33
+ }
34
+ function useOTAUpdates(onUpdateReady, options = {}) {
35
+ const {
36
+ enabled = Updates.isEnabled && !(typeof __DEV__ !== "undefined" && __DEV__)
37
+ } = options;
38
+ const callbacks = useRef({ onUpdateReady, onError: options.onError });
39
+ callbacks.current = { onUpdateReady, onError: options.onError };
40
+ const checked = useRef(false);
41
+ useEffect(() => {
42
+ if (!enabled || checked.current)
43
+ return;
44
+ checked.current = true;
45
+ checkForOTAUpdate((reload) => callbacks.current.onUpdateReady(reload)).catch((error) => callbacks.current.onError?.(error));
46
+ }, [enabled]);
47
+ }
48
+ // src/runtime/push.ts
49
+ import * as Device from "expo-device";
50
+ import * as Notifications from "expo-notifications";
51
+ import { useCallback, useEffect as useEffect2, useState } from "react";
52
+ function createPushNotifications(options) {
53
+ const {
54
+ storage,
55
+ registerDevice,
56
+ confirm,
57
+ onUnavailable,
58
+ onError = (message, error) => console.error(message, error),
59
+ projectId,
60
+ storageKey = "@notifications_permission_asked"
61
+ } = options;
62
+ Notifications.setNotificationHandler({
63
+ handleNotification: async () => options.foregroundBehavior ?? {
64
+ shouldShowBanner: true,
65
+ shouldShowList: true,
66
+ shouldPlaySound: true,
67
+ shouldSetBadge: false
68
+ }
69
+ });
70
+ Notifications.setNotificationChannelAsync("default", options.androidChannel ?? {
71
+ name: "Default",
72
+ importance: Notifications.AndroidImportance.HIGH,
73
+ vibrationPattern: [0, 250, 250, 250]
74
+ }).catch((error) => onError("Failed to create the notification channel", error));
75
+ let pendingPrompt = null;
76
+ const getPermissionStatus = () => Notifications.getPermissionsAsync();
77
+ async function hasPrompted() {
78
+ try {
79
+ return await storage.getItem(storageKey) === "true";
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+ async function requestPermissions() {
85
+ if (!Device.isDevice) {
86
+ onUnavailable?.("not-a-device");
87
+ return false;
88
+ }
89
+ try {
90
+ const { granted } = await Notifications.requestPermissionsAsync();
91
+ if (!granted)
92
+ onUnavailable?.("denied");
93
+ return granted;
94
+ } catch (error) {
95
+ onError("Failed to request notification permissions", error);
96
+ return false;
97
+ }
98
+ }
99
+ async function getPushToken() {
100
+ if (!Device.isDevice)
101
+ return null;
102
+ try {
103
+ const token = await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined);
104
+ return token.data;
105
+ } catch (error) {
106
+ onError("Failed to get the Expo push token", error);
107
+ return null;
108
+ }
109
+ }
110
+ async function syncToken() {
111
+ if (!(await getPermissionStatus()).granted)
112
+ return false;
113
+ const token = await getPushToken();
114
+ if (!token)
115
+ return false;
116
+ try {
117
+ await registerDevice(token);
118
+ return true;
119
+ } catch (error) {
120
+ onError("Failed to register the push token", error);
121
+ return false;
122
+ }
123
+ }
124
+ async function runPrompt() {
125
+ if (await hasPrompted())
126
+ return false;
127
+ try {
128
+ await storage.setItem(storageKey, "true");
129
+ } catch (error) {
130
+ onError("Failed to remember the notification prompt", error);
131
+ }
132
+ if (confirm && !await confirm())
133
+ return false;
134
+ if (!await requestPermissions())
135
+ return false;
136
+ await syncToken();
137
+ return true;
138
+ }
139
+ return {
140
+ getPermissionStatus,
141
+ hasPrompted,
142
+ requestPermissions,
143
+ getPushToken,
144
+ syncToken,
145
+ prompt() {
146
+ pendingPrompt ??= runPrompt().catch((error) => {
147
+ onError("Failed to prompt for notifications", error);
148
+ return false;
149
+ }).finally(() => {
150
+ pendingPrompt = null;
151
+ });
152
+ return pendingPrompt;
153
+ },
154
+ async enable() {
155
+ if (!await requestPermissions())
156
+ return false;
157
+ await syncToken();
158
+ return true;
159
+ },
160
+ disable() {
161
+ return registerDevice(null);
162
+ },
163
+ listen({ onReceived, onResponse }) {
164
+ const subscriptions = [
165
+ onReceived && Notifications.addNotificationReceivedListener(onReceived),
166
+ onResponse && Notifications.addNotificationResponseReceivedListener(onResponse)
167
+ ];
168
+ const launchResponse = onResponse ? Notifications.getLastNotificationResponse() : null;
169
+ if (launchResponse)
170
+ onResponse?.(launchResponse);
171
+ return () => {
172
+ for (const subscription of subscriptions)
173
+ subscription?.remove();
174
+ };
175
+ }
176
+ };
177
+ }
178
+ function usePushNotifications(push, { enabled = false } = {}) {
179
+ const [permission, setPermission] = useState(null);
180
+ const [isLoading, setIsLoading] = useState(false);
181
+ const refresh = useCallback(async () => {
182
+ setPermission(await push.getPermissionStatus());
183
+ }, [push]);
184
+ useEffect2(() => {
185
+ refresh();
186
+ if (enabled)
187
+ push.syncToken();
188
+ }, [push, enabled, refresh]);
189
+ const withLoading = useCallback((action) => async () => {
190
+ setIsLoading(true);
191
+ try {
192
+ return await action();
193
+ } finally {
194
+ await refresh();
195
+ setIsLoading(false);
196
+ }
197
+ }, [refresh]);
198
+ return {
199
+ permission,
200
+ isEnabled: enabled && permission?.granted === true,
201
+ isLoading,
202
+ prompt: withLoading(push.prompt),
203
+ enable: withLoading(push.enable),
204
+ disable: withLoading(push.disable),
205
+ refresh
206
+ };
207
+ }
208
+ export {
209
+ ErrorBoundary,
210
+ checkForOTAUpdate,
211
+ createPushNotifications,
212
+ useOTAUpdates,
213
+ usePushNotifications
214
+ };
@@ -0,0 +1,19 @@
1
+ /** Restarts the app on the downloaded update. */
2
+ export type ReloadApp = () => Promise<void>;
3
+ /**
4
+ * Checks for an EAS update and downloads it. Calls `onUpdateReady` once a new
5
+ * update is on disk; show a "Restart" prompt there and call `reload` to apply.
6
+ * Returns whether a new update was downloaded.
7
+ */
8
+ export declare function checkForOTAUpdate(onUpdateReady: (reload: ReloadApp) => void): Promise<boolean>;
9
+ export interface UseOTAUpdatesOptions {
10
+ /** Defaults to off in development and wherever expo-updates is disabled (Expo Go). */
11
+ enabled?: boolean;
12
+ /** A failed check is retried on the next launch; defaults to ignoring it. */
13
+ onError?: (error: unknown) => void;
14
+ }
15
+ /**
16
+ * Checks for an update once per mount. The callbacks are read through refs, so
17
+ * an unstable toast function never re-runs the check or stacks prompts.
18
+ */
19
+ export declare function useOTAUpdates(onUpdateReady: (reload: ReloadApp) => void, options?: UseOTAUpdatesOptions): void;
@@ -0,0 +1,70 @@
1
+ import * as Notifications from 'expo-notifications';
2
+ /** Async key-value store. AsyncStorage and expo-secure-store adapters fit. */
3
+ export interface KeyValueStorage {
4
+ getItem(key: string): Promise<string | null>;
5
+ setItem(key: string, value: string): Promise<void>;
6
+ }
7
+ export interface PushNotificationsOptions {
8
+ /** Remembers that the one-time prompt ran. */
9
+ storage: KeyValueStorage;
10
+ /** Saves this device's Expo push token on your backend; `null` removes it. */
11
+ registerDevice: (token: string | null) => Promise<void>;
12
+ /**
13
+ * Explains push before the OS dialog. Resolve `true` to continue. Without
14
+ * it, `prompt()` goes straight to the OS dialog.
15
+ */
16
+ confirm?: () => Promise<boolean>;
17
+ /** Push is off: the app runs on a simulator or the user denied permission. */
18
+ onUnavailable?: (reason: 'not-a-device' | 'denied') => void;
19
+ onError?: (message: string, error: unknown) => void;
20
+ /** Defaults to what expo-notifications reads from the app config. */
21
+ projectId?: string;
22
+ /** Android channel `default`, needed for heads-up notifications. */
23
+ androidChannel?: Notifications.NotificationChannelInput;
24
+ /** How notifications show while the app is in the foreground. */
25
+ foregroundBehavior?: Notifications.NotificationBehavior;
26
+ storageKey?: string;
27
+ }
28
+ export interface NotificationListeners {
29
+ onReceived?: (notification: Notifications.Notification) => void;
30
+ /** Also called with the response that launched the app, if any. */
31
+ onResponse?: (response: Notifications.NotificationResponse) => void;
32
+ }
33
+ export type PushNotifications = ReturnType<typeof createPushNotifications>;
34
+ /**
35
+ * expo-notifications behind one object: permission flow, Expo push token and
36
+ * device registration. Create it once at module level.
37
+ */
38
+ export declare function createPushNotifications(options: PushNotificationsOptions): {
39
+ getPermissionStatus: () => Promise<Notifications.NotificationPermissionsStatus>;
40
+ hasPrompted: () => Promise<boolean>;
41
+ requestPermissions: () => Promise<boolean>;
42
+ getPushToken: () => Promise<string | null>;
43
+ syncToken: () => Promise<boolean>;
44
+ /**
45
+ * Asks for push once per install (after sign-in, say), then registers the
46
+ * token. Never throws; concurrent calls share one dialog.
47
+ */
48
+ prompt(): Promise<boolean>;
49
+ /** Turns push on from settings. Resolves whether it is on. */
50
+ enable(): Promise<boolean>;
51
+ /** Removes this device's token from your backend. */
52
+ disable(): Promise<void>;
53
+ /** Subscribes to notifications. Returns the unsubscribe function. */
54
+ listen({ onReceived, onResponse }: NotificationListeners): () => void;
55
+ };
56
+ /**
57
+ * Push state for a settings screen. `enabled` is the user's saved preference;
58
+ * while it is on, the token is re-registered on mount.
59
+ */
60
+ export declare function usePushNotifications(push: PushNotifications, { enabled }?: {
61
+ enabled?: boolean;
62
+ }): {
63
+ permission: Notifications.NotificationPermissionsStatus | null;
64
+ isEnabled: boolean;
65
+ isLoading: boolean;
66
+ prompt: () => Promise<boolean>;
67
+ enable: () => Promise<boolean>;
68
+ disable: () => Promise<void>;
69
+ refresh: () => Promise<void>;
70
+ };
@@ -0,0 +1,8 @@
1
+ import type { ReleasePlatform } from './args';
2
+ export declare const SOURCE_MAP_OUTPUT_DIR = "dist";
3
+ export declare function expoCommand(...args: string[]): string[];
4
+ export declare function sourceMapExportCommand(platform: ReleasePlatform): string[];
5
+ export declare function sourceMapUpdateArgs(): string[];
6
+ export declare function mergeUpdateArgs(extra: string[] | undefined, exportSourceMaps: boolean): string[];
7
+ export declare function defaultHasSourceMaps(directory?: string): Promise<boolean>;
8
+ export declare function assertSourceMaps(hasSourceMaps?: () => Promise<boolean>): Promise<void>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "expo-release",
3
- "version": "2.0.0",
4
- "description": "Fingerprint-driven Expo EAS release: OTA update or native store build",
3
+ "version": "2.2.0",
4
+ "description": "Fingerprint-driven Expo EAS release: OTA update or native store build, plus app runtime helpers",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
7
7
  "main": "./dist/index.js",
@@ -39,25 +39,58 @@
39
39
  "bun": "./src/index.ts",
40
40
  "import": "./dist/index.js",
41
41
  "default": "./dist/index.js"
42
+ },
43
+ "./runtime": {
44
+ "types": "./dist/runtime/index.d.ts",
45
+ "import": "./dist/runtime/index.js",
46
+ "default": "./dist/runtime/index.js"
42
47
  }
43
48
  },
44
49
  "scripts": {
45
50
  "build": "bun run build:js && bun run build:types",
46
- "build:js": "bun build ./src/index.ts ./src/cli.ts --outdir ./dist --target bun --packages external",
51
+ "build:js": "bun build ./src/index.ts ./src/cli.ts --outdir ./dist --target bun --packages external && bun build ./src/runtime/index.ts --outdir ./dist/runtime --target browser --packages external",
47
52
  "build:types": "tsgo -p tsconfig.build.json",
48
53
  "prepublishOnly": "bun run build",
49
- "publish:patch": "npm version patch && npm publish",
50
- "publish:minor": "npm version minor && npm publish",
51
- "publish:major": "npm version major && npm publish",
52
54
  "test": "bun test",
53
55
  "lint": "bun run typecheck && bun biome check",
54
56
  "lint:write": "bun run typecheck && bun biome check --write",
55
- "typecheck": "tsgo --noEmit"
57
+ "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.runtime.json",
58
+ "verify:package": "node scripts/verify-package.mjs"
59
+ },
60
+ "peerDependencies": {
61
+ "expo-device": ">=55",
62
+ "expo-notifications": ">=55",
63
+ "expo-updates": ">=55",
64
+ "react": ">=19"
65
+ },
66
+ "peerDependenciesMeta": {
67
+ "expo-device": {
68
+ "optional": true
69
+ },
70
+ "expo-notifications": {
71
+ "optional": true
72
+ },
73
+ "expo-updates": {
74
+ "optional": true
75
+ },
76
+ "react": {
77
+ "optional": true
78
+ }
56
79
  },
57
80
  "devDependencies": {
58
81
  "@biomejs/biome": "^2.4.6",
59
82
  "@types/bun": "latest",
83
+ "@types/react": "~19.2.0",
60
84
  "@typescript/native-preview": "^7.0.0-dev.20260306.1",
85
+ "expo-device": "~55.0.10",
86
+ "expo-notifications": "~55.0.14",
87
+ "expo-updates": "~55.0.16",
88
+ "react": "19.2.0",
61
89
  "typescript": "^5.7.0"
62
- }
90
+ },
91
+ "homepage": "https://github.com/HansKristoffer/expo-release#readme",
92
+ "bugs": {
93
+ "url": "https://github.com/HansKristoffer/expo-release/issues"
94
+ },
95
+ "packageManager": "bun@1.4.2"
63
96
  }
package/src/args.ts CHANGED
@@ -31,6 +31,7 @@ export type ReleaseOptions = {
31
31
  preUpdate?: string
32
32
  postUpdate?: string
33
33
  updateExtraArgs?: string
34
+ exportSourceMaps: boolean
34
35
  group?: string
35
36
  runtimeVersion?: string
36
37
  message?: string
@@ -39,7 +40,7 @@ export type ReleaseOptions = {
39
40
  }
40
41
 
41
42
  export const USAGE =
42
- 'Usage: expo-release <release|decide|operate> --profile <profile> [--base <sha>] [--head <sha>] [--platform ios|android] [--impact-paths apps/expo/] [--operation deploy|force-native|retry-ota|republish|rollback-embedded] [--deploy-type none|ota|native] [--channel <channel>] [--environment <env>] [--group <id>] [--runtime-version <hash>] [--message <msg>] [--fingerprint <hash>] [--auto-submit true] [--pre-update <cmd>] [--post-update <cmd>] [--update-extra-args <args>] [--dry-run] [--ignore-expo-impact true]'
43
+ 'Usage: expo-release <release|decide|operate> --profile <profile> [--base <sha>] [--head <sha>] [--platform ios|android] [--impact-paths apps/expo/] [--operation deploy|force-native|retry-ota|republish|rollback-embedded] [--deploy-type none|ota|native] [--channel <channel>] [--environment <env>] [--group <id>] [--runtime-version <hash>] [--message <msg>] [--fingerprint <hash>] [--auto-submit true] [--export-source-maps true] [--pre-update <cmd>] [--post-update <cmd>] [--update-extra-args <args>] [--dry-run] [--ignore-expo-impact true]'
43
44
 
44
45
  function fromEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
45
46
  const value = env[name]?.trim()
@@ -156,6 +157,7 @@ export function parseReleaseOptions(
156
157
  'pre-update'?: string
157
158
  'post-update'?: string
158
159
  'update-extra-args'?: string
160
+ 'export-source-maps'?: string
159
161
  group?: string
160
162
  'runtime-version'?: string
161
163
  message?: string
@@ -184,6 +186,7 @@ export function parseReleaseOptions(
184
186
  'pre-update': { type: 'string' },
185
187
  'post-update': { type: 'string' },
186
188
  'update-extra-args': { type: 'string' },
189
+ 'export-source-maps': { type: 'string' },
187
190
  group: { type: 'string' },
188
191
  'runtime-version': { type: 'string' },
189
192
  message: { type: 'string' },
@@ -298,6 +301,13 @@ export function parseReleaseOptions(
298
301
  fromEnv(env, 'EXPO_RELEASE_UPDATE_EXTRA_ARGS')
299
302
  )
300
303
  ),
304
+ exportSourceMaps: parseBoolean(
305
+ firstDefined(
306
+ values['export-source-maps'],
307
+ fromEnv(env, 'EXPO_RELEASE_EXPORT_SOURCE_MAPS')
308
+ ),
309
+ false
310
+ ),
301
311
  group,
302
312
  runtimeVersion: optionalString(
303
313
  firstDefined(
package/src/cli.ts CHANGED
@@ -49,13 +49,20 @@ function toOperationInput(
49
49
  preUpdate: options.preUpdate,
50
50
  postUpdate: options.postUpdate,
51
51
  updateExtraArgs: options.updateExtraArgs,
52
+ exportSourceMaps: options.exportSourceMaps,
52
53
  dryRun: options.dryRun
53
54
  }
54
55
  }
55
56
 
56
57
  async function main() {
57
58
  const raw = Bun.argv.slice(2)
58
- if (raw.length === 0 || raw[0] === '--help' || raw[0] === '-h') {
59
+ // The composite action passes everything through EXPO_RELEASE_* env vars and no argv.
60
+ const hasEnvCommand = Boolean(process.env.EXPO_RELEASE_COMMAND?.trim())
61
+ if (
62
+ (raw.length === 0 && !hasEnvCommand) ||
63
+ raw[0] === '--help' ||
64
+ raw[0] === '-h'
65
+ ) {
59
66
  process.stdout.write(`${USAGE}\n`)
60
67
  return
61
68
  }
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
+ }