expo-release 2.2.0 → 2.3.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/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 {
@@ -54,51 +57,78 @@ function toOperationInput(
54
57
  }
55
58
  }
56
59
 
57
- async function main() {
58
- const raw = Bun.argv.slice(2)
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
- ) {
66
- process.stdout.write(`${USAGE}\n`)
67
- return
68
- }
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
+ }
69
68
 
70
- 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
+ }
71
78
 
72
- switch (options.command) {
73
- case 'decide': {
74
- const decision = await decideRelease(toDecisionInput(options))
75
- await writeDecideGitHubOutput(decision)
76
- process.stdout.write(`${JSON.stringify(decision)}\n`)
77
- return
78
- }
79
- case 'operate': {
80
- const result = await operateRelease(toOperationInput(options, null))
81
- await writeOperateGitHubOutput(result)
82
- process.stdout.write(`${result}\n`)
83
- return
84
- }
85
- case 'release': {
86
- let decision: ReleaseDecision | null = null
87
- if (needsDecide(options.operation)) {
88
- decision = await decideRelease(toDecisionInput(options))
89
- await writeDecideGitHubOutput(decision)
90
- 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)
91
104
  }
92
- const result = await operateRelease(toOperationInput(options, decision))
93
- await writeOperateGitHubOutput(result)
94
- process.stdout.write(`${result}\n`)
95
- return
96
- }
97
- default: {
98
- const _exhaustive: never = options.command
99
- throw new Error(`Unsupported command: ${_exhaustive}`)
100
- }
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
+ })
101
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
102
130
  }
103
131
 
104
- 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
@@ -15,5 +15,6 @@ export {
15
15
  type NotificationListeners,
16
16
  type PushNotifications,
17
17
  type PushNotificationsOptions,
18
+ type UsePushNotificationsOptions,
18
19
  usePushNotifications
19
20
  } from './push'
@@ -9,7 +9,7 @@ export interface KeyValueStorage {
9
9
  }
10
10
 
11
11
  export interface PushNotificationsOptions {
12
- /** Remembers that the one-time prompt ran. */
12
+ /** Remembers that the one-time prompt ran and the last token sent. */
13
13
  storage: KeyValueStorage
14
14
  /** Saves this device's Expo push token on your backend; `null` removes it. */
15
15
  registerDevice: (token: string | null) => Promise<void>
@@ -28,6 +28,8 @@ export interface PushNotificationsOptions {
28
28
  /** How notifications show while the app is in the foreground. */
29
29
  foregroundBehavior?: Notifications.NotificationBehavior
30
30
  storageKey?: string
31
+ /** Holds the last token passed to `registerDevice`. */
32
+ tokenStorageKey?: string
31
33
  }
32
34
 
33
35
  export interface NotificationListeners {
@@ -50,7 +52,8 @@ export function createPushNotifications(options: PushNotificationsOptions) {
50
52
  onUnavailable,
51
53
  onError = (message, error) => console.error(message, error),
52
54
  projectId,
53
- storageKey = '@notifications_permission_asked'
55
+ storageKey = '@notifications_permission_asked',
56
+ tokenStorageKey = '@notifications_push_token'
54
57
  } = options
55
58
 
56
59
  Notifications.setNotificationHandler({
@@ -78,14 +81,26 @@ export function createPushNotifications(options: PushNotificationsOptions) {
78
81
 
79
82
  const getPermissionStatus = () => Notifications.getPermissionsAsync()
80
83
 
81
- async function hasPrompted() {
84
+ async function read(key: string) {
82
85
  try {
83
- return (await storage.getItem(storageKey)) === 'true'
86
+ return await storage.getItem(key)
84
87
  } catch {
85
- return false
88
+ return null
89
+ }
90
+ }
91
+
92
+ async function rememberToken(token: string) {
93
+ try {
94
+ await storage.setItem(tokenStorageKey, token)
95
+ } catch (error) {
96
+ onError('Failed to remember the push token', error)
86
97
  }
87
98
  }
88
99
 
100
+ async function hasPrompted() {
101
+ return (await read(storageKey)) === 'true'
102
+ }
103
+
89
104
  /** Shows the OS dialog when it still can. Resolves whether push is allowed. */
90
105
  async function requestPermissions() {
91
106
  if (!Device.isDevice) {
@@ -102,11 +117,13 @@ export function createPushNotifications(options: PushNotificationsOptions) {
102
117
  }
103
118
  }
104
119
 
105
- async function getPushToken() {
120
+ async function getPushToken(devicePushToken?: Notifications.DevicePushToken) {
106
121
  if (!Device.isDevice) return null
107
122
  try {
108
123
  const token = await Notifications.getExpoPushTokenAsync(
109
- projectId ? { projectId } : undefined
124
+ projectId || devicePushToken
125
+ ? { projectId, devicePushToken }
126
+ : undefined
110
127
  )
111
128
  return token.data
112
129
  } catch (error) {
@@ -115,18 +132,33 @@ export function createPushNotifications(options: PushNotificationsOptions) {
115
132
  }
116
133
  }
117
134
 
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
135
+ async function sendToken(token: string) {
123
136
  try {
124
137
  await registerDevice(token)
125
- return true
126
138
  } catch (error) {
127
139
  onError('Failed to register the push token', error)
128
140
  return false
129
141
  }
142
+ await rememberToken(token)
143
+ return true
144
+ }
145
+
146
+ /** Registers the current token. Resolves whether one was registered. */
147
+ async function syncToken() {
148
+ if (!(await getPermissionStatus()).granted) return false
149
+ const token = await getPushToken()
150
+ return token ? sendToken(token) : false
151
+ }
152
+
153
+ /**
154
+ * Re-registers a token that changed since it was last sent. Runs only while
155
+ * push is on for this device: a token was sent and `disable()` has not run.
156
+ */
157
+ async function refreshToken(devicePushToken?: Notifications.DevicePushToken) {
158
+ const sent = await read(tokenStorageKey)
159
+ if (!sent || !(await getPermissionStatus()).granted) return
160
+ const token = await getPushToken(devicePushToken)
161
+ if (token && token !== sent) await sendToken(token)
130
162
  }
131
163
 
132
164
  async function runPrompt() {
@@ -174,13 +206,24 @@ export function createPushNotifications(options: PushNotificationsOptions) {
174
206
  },
175
207
 
176
208
  /** Removes this device's token from your backend. */
177
- disable() {
178
- return registerDevice(null)
209
+ async disable() {
210
+ await registerDevice(null)
211
+ // KeyValueStorage has no removeItem; an empty token means none was sent.
212
+ await rememberToken('')
179
213
  },
180
214
 
181
- /** Subscribes to notifications. Returns the unsubscribe function. */
182
- listen({ onReceived, onResponse }: NotificationListeners) {
215
+ /**
216
+ * Subscribes to notifications and keeps the registered token current: on
217
+ * start and whenever Expo rotates it. Returns the unsubscribe function.
218
+ */
219
+ listen({ onReceived, onResponse }: NotificationListeners = {}) {
220
+ const refresh = (devicePushToken?: Notifications.DevicePushToken) =>
221
+ refreshToken(devicePushToken).catch((error: unknown) =>
222
+ onError('Failed to refresh the push token', error)
223
+ )
224
+ void refresh()
183
225
  const subscriptions = [
226
+ Notifications.addPushTokenListener(refresh),
184
227
  onReceived && Notifications.addNotificationReceivedListener(onReceived),
185
228
  onResponse &&
186
229
  Notifications.addNotificationResponseReceivedListener(onResponse)
@@ -196,27 +239,55 @@ export function createPushNotifications(options: PushNotificationsOptions) {
196
239
  }
197
240
  }
198
241
 
242
+ export interface UsePushNotificationsOptions {
243
+ /** The user's saved preference, for example from your API. */
244
+ enabled?: boolean
245
+ /**
246
+ * Saves the preference. `enable()` and `disable()` call it first and show
247
+ * the new value at once; when it rejects, they roll back and rethrow.
248
+ */
249
+ persist?: (enabled: boolean) => Promise<unknown>
250
+ }
251
+
199
252
  /**
200
253
  * Push state for a settings screen. `enabled` is the user's saved preference;
201
254
  * while it is on, the token is re-registered on mount.
202
255
  */
203
256
  export function usePushNotifications(
204
257
  push: PushNotifications,
205
- { enabled = false }: { enabled?: boolean } = {}
258
+ { enabled = false, persist }: UsePushNotificationsOptions = {}
206
259
  ) {
207
260
  const [permission, setPermission] =
208
261
  useState<Notifications.NotificationPermissionsStatus | null>(null)
209
262
  const [isLoading, setIsLoading] = useState(false)
263
+ // Set by enable/disable until `enabled` catches up with the saved value.
264
+ const [optimistic, setOptimistic] = useState<boolean | null>(null)
265
+ const preference = optimistic ?? enabled
210
266
 
211
267
  const refresh = useCallback(async () => {
212
268
  setPermission(await push.getPermissionStatus())
213
269
  }, [push])
214
270
 
215
271
  useEffect(() => {
272
+ setOptimistic(null)
216
273
  void refresh()
217
274
  if (enabled) void push.syncToken()
218
275
  }, [push, enabled, refresh])
219
276
 
277
+ const save = useCallback(
278
+ async (next: boolean) => {
279
+ if (!persist) return
280
+ setOptimistic(next)
281
+ try {
282
+ await persist(next)
283
+ } catch (error) {
284
+ setOptimistic(null)
285
+ throw error
286
+ }
287
+ },
288
+ [persist]
289
+ )
290
+
220
291
  const withLoading = useCallback(
221
292
  <T>(action: () => Promise<T>) =>
222
293
  async () => {
@@ -233,11 +304,17 @@ export function usePushNotifications(
233
304
 
234
305
  return {
235
306
  permission,
236
- isEnabled: enabled && permission?.granted === true,
307
+ isEnabled: preference && permission?.granted === true,
237
308
  isLoading,
238
309
  prompt: withLoading(push.prompt),
239
- enable: withLoading(push.enable),
240
- disable: withLoading(push.disable),
310
+ enable: withLoading(async () => {
311
+ await save(true)
312
+ return push.enable()
313
+ }),
314
+ disable: withLoading(async () => {
315
+ await save(false)
316
+ await push.disable()
317
+ }),
241
318
  refresh
242
319
  }
243
320
  }