@rific/updater 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jay Deaton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # @rific/updater
2
+
3
+ OTA update hook for Expo apps. Silently stages updates in the background when the app is foregrounded, and exposes a manual `check()` for settings screens. No surprise restarts — the user always confirms before the app reloads.
4
+
5
+ ---
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @rific/updater
11
+ ```
12
+
13
+ **Peer dependencies:** `expo-updates`, `react`, `react-native`
14
+
15
+ ---
16
+
17
+ ## Usage
18
+
19
+ ### Basic
20
+
21
+ ```tsx
22
+ import { useUpdater } from '@rific/updater'
23
+
24
+ const { check, checking, updateReady } = useUpdater()
25
+ ```
26
+
27
+ Call `check()` from a "Check for Updates" button. The `updateReady` flag goes `true` after a silent background fetch — use it to show a badge on your settings icon.
28
+
29
+ ### Settings screen
30
+
31
+ ```tsx
32
+ const { check, checking, updateReady } = useUpdater({
33
+ onError: (msg) => toast(msg),
34
+ })
35
+
36
+ <MenuItem
37
+ title="Check for Update"
38
+ caption={`v${release.otaVersion}${updateReady ? ' — update ready' : ''}`}
39
+ loading={checking}
40
+ onPress={check}
41
+ />
42
+ ```
43
+
44
+ ### With a custom confirm dialog
45
+
46
+ ```tsx
47
+ const { check, checking } = useUpdater({
48
+ onConfirm: async (manifest) => {
49
+ // return true to proceed with reload, false to cancel
50
+ return myCustomDialog(manifest)
51
+ },
52
+ })
53
+ ```
54
+
55
+ ### Disable automatic foreground check
56
+
57
+ ```tsx
58
+ const { check, checking } = useUpdater({ autoCheck: false })
59
+ ```
60
+
61
+ ---
62
+
63
+ ## API
64
+
65
+ ### `useUpdater(options?)`
66
+
67
+ ```ts
68
+ interface UseUpdaterOptions {
69
+ autoCheck?: boolean // default: true
70
+ onConfirm?: (manifest: UpdateManifest) => Promise<boolean>
71
+ onError?: (message: string) => void
72
+ }
73
+
74
+ interface UseUpdaterReturn {
75
+ check: () => Promise<void>
76
+ checking: boolean
77
+ updateReady: boolean
78
+ }
79
+ ```
80
+
81
+ | Option | Default | Description |
82
+ |--------|---------|-------------|
83
+ | `autoCheck` | `true` | Registers an `AppState` listener that silently fetches available updates whenever the app comes to the foreground. Disable for games or apps that want full manual control. |
84
+ | `onConfirm` | — | Custom confirmation dialog. Receives the update manifest, must return `Promise<boolean>` — `true` to reload, `false` to cancel. Defaults to a native `Alert` showing the release date and metadata message. |
85
+ | `onError` | — | Called with an error message string if `check()` throws. Defaults to `Alert.alert`. |
86
+
87
+ | Return | Description |
88
+ |--------|-------------|
89
+ | `check()` | Manual update check. Shows a dev/web guard alert if unsupported. If a background fetch already staged an update, uses that manifest directly (no extra network call). Clears `updateReady` on completion regardless of whether the user confirmed. |
90
+ | `checking` | `true` while `check()` is in flight. Safe to drive a loading spinner. Concurrent calls are ignored via a ref guard. |
91
+ | `updateReady` | `true` after the background fetch successfully staged an update. Cleared when `check()` completes. Use to show a badge on a settings button. |
92
+
93
+ ---
94
+
95
+ ## How updates work
96
+
97
+ **Automatic (foreground):** When `autoCheck` is `true`, the hook registers an `AppState` listener. Each time the app returns from background/inactive to active, it calls `checkForUpdateAsync()` + `fetchUpdateAsync()` silently. The downloaded bundle sits on disk — no prompt, no restart. The **next cold launch** automatically runs it.
98
+
99
+ **Manual (`check()`):** Runs the full flow — check (or reuse staged manifest) → confirmation dialog → `reloadAsync()`. The user sees what was released and chooses whether to restart now.
100
+
101
+ **Web / DEV:** Both are no-ops. `check()` shows an informational alert explaining why. The foreground listener is never registered.
102
+
103
+ ---
104
+
105
+ ## OTA version constant
106
+
107
+ Each app maintains a local integer version displayed to users (separate from the semver app version). Bump it before pushing an OTA:
108
+
109
+ ```sh
110
+ # from your app's root
111
+ npx rific-bump-ota src/constants/release.ts
112
+ ```
113
+
114
+ Or add to your app's `package.json`:
115
+
116
+ ```json
117
+ "scripts": {
118
+ "update:bump": "rific-bump-ota src/constants/release.ts"
119
+ }
120
+ ```
121
+
122
+ The script:
123
+ - Verifies git working directory is clean
124
+ - Increments `otaVersion` in the target file
125
+ - Auto-commits `"otaVersion N -> N+1"`
126
+
127
+ File format expected (TypeScript or JS object literal):
128
+
129
+ ```ts
130
+ export const release = {
131
+ otaVersion: 1
132
+ }
133
+ ```
134
+
135
+ The path argument defaults to `src/constants/release.ts` if omitted.
136
+
137
+ ---
138
+
139
+ ## Context / design notes
140
+
141
+ - Named `@rific/updater` (not `expo-updater`) to avoid confusion with the `expo-updates` peer dependency
142
+ - `check()` uses a ref guard (`checkingRef`) rather than the `checking` state to prevent concurrent calls — state batching means a second call could see stale `false` before the first render commits
143
+ - `updateReady` and the staged manifest ref are cleared in `finally` so they reset on both confirm and cancel
144
+ - `onConfirm` replaces the default `Alert` entirely — useful in apps that have their own dialog primitive (e.g. a `select()` utility or bottom sheet)
145
+ - No Provider or context required — the hook is self-contained
146
+
147
+ ---
148
+
149
+ ## Consuming apps
150
+
151
+ - **Lumber** (`../Lumber`) — account screen, shows version + update badge
152
+ - **CashierFu-Utility** (`../CashierFu-Utility`) — settings modal, uses `@rific/toaster` for `onError`
153
+ - Games (Setter, Hangman, Crumby, HexFleet, etc.) — use `autoCheck: true`, no manual check needed
154
+
155
+ ### Local development (yalc)
156
+
157
+ ```sh
158
+ # in this repo
159
+ yalc publish
160
+
161
+ # in the consuming app
162
+ yalc add @rific/updater
163
+ ```
164
+
165
+ Use `yalc` not `npm link` — Metro doesn't resolve symlinks reliably.
@@ -0,0 +1,18 @@
1
+ interface UpdateManifest {
2
+ createdAt: string;
3
+ metadata?: Record<string, string | undefined>;
4
+ }
5
+
6
+ interface UseUpdaterOptions {
7
+ autoCheck?: boolean;
8
+ onConfirm?: (manifest: UpdateManifest) => Promise<boolean>;
9
+ onError?: (message: string) => void;
10
+ }
11
+ interface UseUpdaterReturn {
12
+ check: () => Promise<void>;
13
+ checking: boolean;
14
+ updateReady: boolean;
15
+ }
16
+ declare const useUpdater: (options?: UseUpdaterOptions) => UseUpdaterReturn;
17
+
18
+ export { type UpdateManifest, type UseUpdaterOptions, type UseUpdaterReturn, useUpdater };
@@ -0,0 +1,18 @@
1
+ interface UpdateManifest {
2
+ createdAt: string;
3
+ metadata?: Record<string, string | undefined>;
4
+ }
5
+
6
+ interface UseUpdaterOptions {
7
+ autoCheck?: boolean;
8
+ onConfirm?: (manifest: UpdateManifest) => Promise<boolean>;
9
+ onError?: (message: string) => void;
10
+ }
11
+ interface UseUpdaterReturn {
12
+ check: () => Promise<void>;
13
+ checking: boolean;
14
+ updateReady: boolean;
15
+ }
16
+ declare const useUpdater: (options?: UseUpdaterOptions) => UseUpdaterReturn;
17
+
18
+ export { type UpdateManifest, type UseUpdaterOptions, type UseUpdaterReturn, useUpdater };
package/dist/index.js ADDED
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ useUpdater: () => useUpdater
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/useUpdater.ts
28
+ var import_expo_updates2 = require("expo-updates");
29
+ var import_react = require("react");
30
+ var import_react_native2 = require("react-native");
31
+
32
+ // src/checkForUpdate.ts
33
+ var import_expo_updates = require("expo-updates");
34
+ var checkForUpdate = async () => {
35
+ const { isAvailable } = await (0, import_expo_updates.checkForUpdateAsync)();
36
+ if (!isAvailable) return null;
37
+ const { manifest } = await (0, import_expo_updates.fetchUpdateAsync)();
38
+ return manifest ? manifest : null;
39
+ };
40
+
41
+ // src/getUpdateConfirmation.ts
42
+ var import_react_native = require("react-native");
43
+ var getUpdateConfirmation = (manifest) => {
44
+ const date = new Date(manifest.createdAt);
45
+ let info = `A new update was released on ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}.`;
46
+ if (manifest.metadata?.message) info += `
47
+
48
+ Message: ${manifest.metadata.message}.`;
49
+ info += "\n\nRestart app to update.";
50
+ return new Promise((resolve) => {
51
+ import_react_native.Alert.alert("Update available", info, [
52
+ { text: "Cancel", style: "cancel", onPress: () => resolve(false) },
53
+ { text: "Restart", onPress: () => resolve(true) }
54
+ ]);
55
+ });
56
+ };
57
+
58
+ // src/useUpdater.ts
59
+ var isUnsupported = () => __DEV__ || import_react_native2.Platform.OS === "web";
60
+ var useUpdater = (options = {}) => {
61
+ const { autoCheck = true, onConfirm, onError } = options;
62
+ const [checking, setChecking] = (0, import_react.useState)(false);
63
+ const [updateReady, setUpdateReady] = (0, import_react.useState)(false);
64
+ const checkingRef = (0, import_react.useRef)(false);
65
+ const appState = (0, import_react.useRef)(import_react_native2.AppState.currentState);
66
+ const stagedManifest = (0, import_react.useRef)(null);
67
+ (0, import_react.useEffect)(() => {
68
+ if (!autoCheck || isUnsupported()) return;
69
+ const subscription = import_react_native2.AppState.addEventListener("change", (nextState) => {
70
+ if (/inactive|background/.test(appState.current) && nextState === "active") {
71
+ checkForUpdate().then((manifest) => {
72
+ if (manifest) {
73
+ stagedManifest.current = manifest;
74
+ setUpdateReady(true);
75
+ }
76
+ }).catch(() => {
77
+ });
78
+ }
79
+ appState.current = nextState;
80
+ });
81
+ return () => subscription.remove();
82
+ }, [autoCheck]);
83
+ const check = async () => {
84
+ if (__DEV__) {
85
+ import_react_native2.Alert.alert("Updates unavailable", "Update checks are disabled in development mode.");
86
+ return;
87
+ }
88
+ if (import_react_native2.Platform.OS === "web") {
89
+ import_react_native2.Alert.alert("Updates unavailable", "Update checks are not supported on web.");
90
+ return;
91
+ }
92
+ if (checkingRef.current) return;
93
+ checkingRef.current = true;
94
+ setChecking(true);
95
+ try {
96
+ const manifest = stagedManifest.current ?? await checkForUpdate();
97
+ if (!manifest) {
98
+ import_react_native2.Alert.alert("No update", "You are on the most recent version.");
99
+ return;
100
+ }
101
+ const confirmFn = onConfirm ?? getUpdateConfirmation;
102
+ const confirmed = await confirmFn(manifest);
103
+ if (!confirmed) return;
104
+ await (0, import_expo_updates2.reloadAsync)();
105
+ } catch (err) {
106
+ const message = err instanceof Error ? err.message : "Could not check for updates.";
107
+ if (onError) onError(message);
108
+ else import_react_native2.Alert.alert("Update error", message);
109
+ } finally {
110
+ stagedManifest.current = null;
111
+ setUpdateReady(false);
112
+ checkingRef.current = false;
113
+ setChecking(false);
114
+ }
115
+ };
116
+ return { check, checking, updateReady };
117
+ };
118
+ // Annotate the CommonJS export names for ESM import in node:
119
+ 0 && (module.exports = {
120
+ useUpdater
121
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // src/useUpdater.ts
2
+ import { reloadAsync } from "expo-updates";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { Alert as Alert2, AppState, Platform } from "react-native";
5
+
6
+ // src/checkForUpdate.ts
7
+ import { checkForUpdateAsync, fetchUpdateAsync } from "expo-updates";
8
+ var checkForUpdate = async () => {
9
+ const { isAvailable } = await checkForUpdateAsync();
10
+ if (!isAvailable) return null;
11
+ const { manifest } = await fetchUpdateAsync();
12
+ return manifest ? manifest : null;
13
+ };
14
+
15
+ // src/getUpdateConfirmation.ts
16
+ import { Alert } from "react-native";
17
+ var getUpdateConfirmation = (manifest) => {
18
+ const date = new Date(manifest.createdAt);
19
+ let info = `A new update was released on ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}.`;
20
+ if (manifest.metadata?.message) info += `
21
+
22
+ Message: ${manifest.metadata.message}.`;
23
+ info += "\n\nRestart app to update.";
24
+ return new Promise((resolve) => {
25
+ Alert.alert("Update available", info, [
26
+ { text: "Cancel", style: "cancel", onPress: () => resolve(false) },
27
+ { text: "Restart", onPress: () => resolve(true) }
28
+ ]);
29
+ });
30
+ };
31
+
32
+ // src/useUpdater.ts
33
+ var isUnsupported = () => __DEV__ || Platform.OS === "web";
34
+ var useUpdater = (options = {}) => {
35
+ const { autoCheck = true, onConfirm, onError } = options;
36
+ const [checking, setChecking] = useState(false);
37
+ const [updateReady, setUpdateReady] = useState(false);
38
+ const checkingRef = useRef(false);
39
+ const appState = useRef(AppState.currentState);
40
+ const stagedManifest = useRef(null);
41
+ useEffect(() => {
42
+ if (!autoCheck || isUnsupported()) return;
43
+ const subscription = AppState.addEventListener("change", (nextState) => {
44
+ if (/inactive|background/.test(appState.current) && nextState === "active") {
45
+ checkForUpdate().then((manifest) => {
46
+ if (manifest) {
47
+ stagedManifest.current = manifest;
48
+ setUpdateReady(true);
49
+ }
50
+ }).catch(() => {
51
+ });
52
+ }
53
+ appState.current = nextState;
54
+ });
55
+ return () => subscription.remove();
56
+ }, [autoCheck]);
57
+ const check = async () => {
58
+ if (__DEV__) {
59
+ Alert2.alert("Updates unavailable", "Update checks are disabled in development mode.");
60
+ return;
61
+ }
62
+ if (Platform.OS === "web") {
63
+ Alert2.alert("Updates unavailable", "Update checks are not supported on web.");
64
+ return;
65
+ }
66
+ if (checkingRef.current) return;
67
+ checkingRef.current = true;
68
+ setChecking(true);
69
+ try {
70
+ const manifest = stagedManifest.current ?? await checkForUpdate();
71
+ if (!manifest) {
72
+ Alert2.alert("No update", "You are on the most recent version.");
73
+ return;
74
+ }
75
+ const confirmFn = onConfirm ?? getUpdateConfirmation;
76
+ const confirmed = await confirmFn(manifest);
77
+ if (!confirmed) return;
78
+ await reloadAsync();
79
+ } catch (err) {
80
+ const message = err instanceof Error ? err.message : "Could not check for updates.";
81
+ if (onError) onError(message);
82
+ else Alert2.alert("Update error", message);
83
+ } finally {
84
+ stagedManifest.current = null;
85
+ setUpdateReady(false);
86
+ checkingRef.current = false;
87
+ setChecking(false);
88
+ }
89
+ };
90
+ return { check, checking, updateReady };
91
+ };
92
+ export {
93
+ useUpdater
94
+ };
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "@rific/updater",
3
+ "version": "0.1.0",
4
+ "description": "OTA update hook for Expo apps — silent background fetch on foreground, manual check with confirmation dialog",
5
+ "keywords": [
6
+ "expo",
7
+ "expo-updates",
8
+ "ota",
9
+ "react-native",
10
+ "update"
11
+ ],
12
+ "homepage": "https://github.com/jayrdeaton/expo-updater#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/jayrdeaton/expo-updater/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/jayrdeaton/expo-updater.git"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Jay Deaton",
22
+ "sideEffects": false,
23
+ "type": "commonjs",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "react-native": "./dist/index.js",
28
+ "import": "./dist/index.mjs",
29
+ "require": "./dist/index.js"
30
+ }
31
+ },
32
+ "main": "dist/index.js",
33
+ "module": "dist/index.mjs",
34
+ "types": "dist/index.d.ts",
35
+ "bin": {
36
+ "rific-bump-ota": "./scripts/bump-ota-version.mjs"
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "scripts"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
44
+ "fix": "eslint --fix",
45
+ "lint": "eslint",
46
+ "prepublishOnly": "npm run build",
47
+ "release": "git push --follow-tags",
48
+ "release:major": "npm version major && git push --follow-tags",
49
+ "release:minor": "npm version minor && git push --follow-tags",
50
+ "release:patch": "npm version patch && git push --follow-tags",
51
+ "test": "jest",
52
+ "test:watch": "jest --watchAll",
53
+ "typecheck": "tsc --noEmit",
54
+ "preversion": "npm run lint && npm test"
55
+ },
56
+ "devDependencies": {
57
+ "@testing-library/react": "^16.3.2",
58
+ "@types/jest": "^30.0.0",
59
+ "@types/react": "^19.0.0",
60
+ "@typescript-eslint/parser": "^8.59.3",
61
+ "eslint": "^9.39.4",
62
+ "eslint-config-prettier": "^10.1.8",
63
+ "eslint-plugin-package-json": "^1.0.0",
64
+ "eslint-plugin-prettier": "^5.5.5",
65
+ "eslint-plugin-react-native": "^5.0.0",
66
+ "eslint-plugin-simple-import-sort": "^13.0.0",
67
+ "expo-updates": ">=0.25.0",
68
+ "jest": "^30.4.2",
69
+ "jest-environment-jsdom": "^30.4.1",
70
+ "prettier": "^3.8.3",
71
+ "react": "^19.2.6",
72
+ "react-native": "^0.85.3",
73
+ "ts-jest": "^29.4.9",
74
+ "tsup": "^8.0.0",
75
+ "typescript": "^5.9.3",
76
+ "typescript-eslint": "^8.59.3"
77
+ },
78
+ "peerDependencies": {
79
+ "expo-updates": ">=0.25.0",
80
+ "react": ">=17.0.0",
81
+ "react-native": ">=0.70.0"
82
+ },
83
+ "publishConfig": {
84
+ "access": "public"
85
+ }
86
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ import { execSync } from 'node:child_process'
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+
7
+ const filePath = process.argv[2] ?? 'src/constants/release.ts'
8
+ const configPath = path.resolve(process.cwd(), filePath)
9
+
10
+ if (!fs.existsSync(configPath)) {
11
+ console.error(`File not found: ${configPath}`)
12
+ process.exit(1)
13
+ }
14
+
15
+ try {
16
+ const status = execSync('git status --porcelain').toString().trim()
17
+ if (status) {
18
+ console.error('Git working directory is not clean. Please commit or stash your changes first.')
19
+ process.exit(1)
20
+ }
21
+ } catch (err) {
22
+ console.error('Failed to check git status:', err.message)
23
+ process.exit(1)
24
+ }
25
+
26
+ const source = fs.readFileSync(configPath, 'utf8')
27
+ const match = source.match(/(otaVersion:\s*)(\d+)/)
28
+
29
+ if (!match) {
30
+ console.error(`Could not find otaVersion in ${filePath}`)
31
+ process.exit(1)
32
+ }
33
+
34
+ const current = Number.parseInt(match[2], 10)
35
+ const next = current + 1
36
+ const updated = source.replace(/(otaVersion:\s*)(\d+)/, `$1${next}`)
37
+
38
+ fs.writeFileSync(configPath, updated)
39
+
40
+ try {
41
+ execSync(`git add ${configPath}`)
42
+ execSync(`git commit -m "otaVersion ${current} -> ${next}"`)
43
+ } catch (err) {
44
+ console.error('Auto-commit failed:', err.message)
45
+ }
46
+
47
+ console.log(`otaVersion bumped: ${current} -> ${next}`)