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/README.md +266 -10
- package/action.yml +5 -0
- package/dist/args.d.ts +2 -1
- package/dist/cli.js +82 -6
- package/dist/index.js +81 -7
- package/dist/operate.d.ts +2 -0
- package/dist/run.d.ts +4 -0
- package/dist/runtime/error-boundary.d.ts +27 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +214 -0
- package/dist/runtime/ota.d.ts +19 -0
- package/dist/runtime/push.d.ts +70 -0
- package/dist/source-maps.d.ts +8 -0
- package/package.json +41 -8
- package/src/args.ts +11 -1
- package/src/cli.ts +8 -1
- package/src/operate.ts +19 -1
- package/src/run.ts +33 -4
- package/src/runtime/error-boundary.ts +51 -0
- package/src/runtime/index.ts +19 -0
- package/src/runtime/ota.ts +54 -0
- package/src/runtime/push.ts +243 -0
- package/src/source-maps.ts +61 -0
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
+
}
|