@forthtilliath/expo-release-updates 0.2.0 → 0.4.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 CHANGED
@@ -22,18 +22,40 @@ Or, from within this monorepo, as a workspace dependency:
22
22
 
23
23
  ## Usage
24
24
 
25
- Each function is its own module — import the file you need directly:
25
+ Each function is its own module — import the file you need directly, or use
26
+ the barrel to pull everything from a single import:
26
27
 
27
28
  ```ts
28
29
  import { compareVersions } from "@forthtilliath/expo-release-updates/compareVersions";
30
+ import { isUpdateAvailable } from "@forthtilliath/expo-release-updates/isUpdateAvailable";
29
31
  import {
30
32
  fetchLatestRelease,
31
33
  fetchReleaseHistory,
32
34
  } from "@forthtilliath/expo-release-updates/githubReleases";
33
35
  import { downloadAndInstallApk } from "@forthtilliath/expo-release-updates/downloadAndInstallApk";
34
36
  import { parseChangelogNotes } from "@forthtilliath/expo-release-updates/parseChangelogNotes";
37
+
38
+ // Or, everything at once:
39
+ import {
40
+ compareVersions,
41
+ isUpdateAvailable,
42
+ fetchLatestRelease,
43
+ fetchReleaseHistory,
44
+ downloadAndInstallApk,
45
+ parseChangelogNotes,
46
+ } from "@forthtilliath/expo-release-updates";
35
47
  ```
36
48
 
49
+ **Avoid the barrel under Jest (or any other CommonJS `require` consumer).**
50
+ `export * from` re-exports are evaluated eagerly on `require()` — unlike
51
+ Metro's ESM bundling, there's no tree-shaking to skip the unused ones.
52
+ Requiring the barrel from _any_ file, even one that only wants
53
+ `compareVersions`, pulls in `downloadAndInstallApk`'s module graph too,
54
+ including native-module imports (`expo-file-system`, `expo-intent-launcher`)
55
+ that don't exist in a Jest environment — this throws at require time, not
56
+ just at runtime for unused code. Deep imports only ever load the one module
57
+ you asked for, so they don't have this problem in any environment.
58
+
37
59
  ### `compareVersions(a, b)`
38
60
 
39
61
  Compares two `"x.y.z"` version strings, segment by segment. Returns `-1` if `a < b`, `0` if equal, `1` if `a > b`.
@@ -44,18 +66,28 @@ compareVersions("2.0.0", "1.9.9"); // 1
44
66
  compareVersions("1.2.3", "1.2.3"); // 0
45
67
  ```
46
68
 
47
- ### `fetchLatestRelease({ owner, repo })`
69
+ ### `isUpdateAvailable(currentVersion, latestVersion)`
70
+
71
+ Named wrapper around `compareVersions` for the common "should I show the update prompt?" check — reads as intent instead of a bare `compareVersions(a, b) > 0` repeated at every call site.
72
+
73
+ ```ts
74
+ if (release && isUpdateAvailable(currentVersion, release.version)) {
75
+ // an update is available
76
+ }
77
+ ```
78
+
79
+ ### `fetchLatestRelease({ owner, repo, token? })`
48
80
 
49
- Fetches the latest GitHub release and its `.apk` asset. Returns `null` if the latest release has no `.apk` attached; throws if the GitHub API request fails.
81
+ Fetches the latest GitHub release and its `.apk` asset. Returns `null` if the latest release has no `.apk` attached; throws if the GitHub API request fails. Pass `token` (a GitHub personal access token or `GITHUB_TOKEN`) for private repos, or to raise the API rate limit from 60 to 5000 requests/hour on public ones.
50
82
 
51
83
  ```ts
52
84
  const release = await fetchLatestRelease({ owner: "acme", repo: "app" });
53
- if (release && compareVersions(release.version, currentVersion) > 0) {
85
+ if (release && isUpdateAvailable(currentVersion, release.version)) {
54
86
  // an update is available: release.version, release.notes, release.apkUrl
55
87
  }
56
88
  ```
57
89
 
58
- ### `fetchReleaseHistory({ owner, repo, limit? })`
90
+ ### `fetchReleaseHistory({ owner, repo, limit?, token? })`
59
91
 
60
92
  Fetches the most recent releases (version, notes, publish date), most recent first. `limit` defaults to `10` — useful for a "release history" / "what's new" screen.
61
93
 
@@ -68,15 +100,16 @@ const history = await fetchReleaseHistory({
68
100
  // [{ version: "1.11.0", notes: "### Added\n- ...", publishedAt: "2026-07-30T..." }, ...]
69
101
  ```
70
102
 
71
- ### `downloadAndInstallApk({ apkUrl, fileName, onProgress? })`
103
+ ### `downloadAndInstallApk({ apkUrl, fileName, onProgress?, expectedMd5? })`
72
104
 
73
- Downloads an APK to the app's cache directory and triggers the Android install-package intent. **Android only** — there is no iOS equivalent (sideloading isn't possible there).
105
+ Downloads an APK to the app's cache directory and triggers the Android install-package intent. **Android only** — there is no iOS equivalent (sideloading isn't possible there). Pass `expectedMd5` to verify the downloaded file's integrity before installing — a mismatch deletes the file and throws instead. GitHub Releases has no built-in signature check the way an app store does, so this is the standard safeguard against a corrupted or tampered download; publish the checksum alongside the release (e.g. in the release notes or a `.md5` asset).
74
106
 
75
107
  ```ts
76
108
  await downloadAndInstallApk({
77
109
  apkUrl: release.apkUrl,
78
110
  fileName: "myapp-update.apk",
79
111
  onProgress: (fraction) => setProgress(fraction),
112
+ expectedMd5: release.apkMd5,
80
113
  });
81
114
  ```
82
115
 
@@ -3,11 +3,22 @@ export interface DownloadAndInstallApkOptions {
3
3
  /** Temp filename in the cache directory, e.g. "myapp-update.apk". */
4
4
  fileName: string;
5
5
  onProgress?: (fraction: number) => void;
6
+ /**
7
+ * MD5 checksum the downloaded file is expected to match (case-insensitive),
8
+ * e.g. one published alongside the release. When set, a mismatch deletes
9
+ * the file and rejects instead of installing it — standard integrity check
10
+ * before running a sideloaded binary, since GitHub Releases has no
11
+ * built-in signature verification the way an app store does.
12
+ */
13
+ expectedMd5?: string;
6
14
  }
7
15
  /**
8
16
  * Downloads an APK to the cache directory and triggers the Android
9
17
  * install-package intent. Android only — there is no equivalent flow on iOS,
10
18
  * which doesn't allow installing a downloaded package outside the App Store.
19
+ *
20
+ * @throws if the download fails, or if `expectedMd5` is set and doesn't
21
+ * match the downloaded file.
11
22
  */
12
- export declare function downloadAndInstallApk({ apkUrl, fileName, onProgress, }: DownloadAndInstallApkOptions): Promise<void>;
23
+ export declare function downloadAndInstallApk({ apkUrl, fileName, onProgress, expectedMd5, }: DownloadAndInstallApkOptions): Promise<void>;
13
24
  //# sourceMappingURL=downloadAndInstallApk.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"downloadAndInstallApk.d.ts","sourceRoot":"","sources":["../src/downloadAndInstallApk.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,4BAA4B;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED;;;;GAIG;AACH,wBAAsB,qBAAqB,CAAC,EAC1C,MAAM,EACN,QAAQ,EACR,UAAU,GACX,EAAE,4BAA4B,GAAG,OAAO,CAAC,IAAI,CAAC,CAiB9C"}
1
+ {"version":3,"file":"downloadAndInstallApk.d.ts","sourceRoot":"","sources":["../src/downloadAndInstallApk.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,4BAA4B;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CAAC,EAC1C,MAAM,EACN,QAAQ,EACR,UAAU,EACV,WAAW,GACZ,EAAE,4BAA4B,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B9C"}
@@ -4,8 +4,11 @@ import * as IntentLauncher from "expo-intent-launcher";
4
4
  * Downloads an APK to the cache directory and triggers the Android
5
5
  * install-package intent. Android only — there is no equivalent flow on iOS,
6
6
  * which doesn't allow installing a downloaded package outside the App Store.
7
+ *
8
+ * @throws if the download fails, or if `expectedMd5` is set and doesn't
9
+ * match the downloaded file.
7
10
  */
8
- export async function downloadAndInstallApk({ apkUrl, fileName, onProgress, }) {
11
+ export async function downloadAndInstallApk({ apkUrl, fileName, onProgress, expectedMd5, }) {
9
12
  const destination = new File(Paths.cache, fileName);
10
13
  if (destination.exists)
11
14
  destination.delete();
@@ -18,6 +21,13 @@ export async function downloadAndInstallApk({ apkUrl, fileName, onProgress, }) {
18
21
  const file = await task.downloadAsync();
19
22
  if (!file)
20
23
  throw new Error("The download failed.");
24
+ if (expectedMd5) {
25
+ const actualMd5 = file.info({ md5: true }).md5;
26
+ if (actualMd5?.toLowerCase() !== expectedMd5.toLowerCase()) {
27
+ file.delete();
28
+ throw new Error(`APK checksum mismatch: expected ${expectedMd5}, got ${actualMd5 ?? "none"}.`);
29
+ }
30
+ }
21
31
  await IntentLauncher.startActivityAsync("android.intent.action.VIEW", {
22
32
  data: file.contentUri,
23
33
  flags: 1,
@@ -1,6 +1,12 @@
1
1
  export interface GithubRepoRef {
2
2
  owner: string;
3
3
  repo: string;
4
+ /**
5
+ * A GitHub personal access token (or `GITHUB_TOKEN` in CI), sent as a
6
+ * `Bearer` token. Required for private repos; also raises the API rate
7
+ * limit from 60 to 5000 requests/hour for public ones.
8
+ */
9
+ token?: string;
4
10
  }
5
11
  export interface LatestRelease {
6
12
  version: string;
@@ -1 +1 @@
1
- {"version":3,"file":"githubReleases.d.ts","sourceRoot":"","sources":["../src/githubReleases.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAkBD;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,aAAa,GACjB,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAkB/B;AAED;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,aAAa,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACtC,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAiBhC"}
1
+ {"version":3,"file":"githubReleases.d.ts","sourceRoot":"","sources":["../src/githubReleases.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAyBD;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,aAAa,GACjB,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAkB/B;AAED;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,aAAa,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACtC,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAehC"}
@@ -1,6 +1,12 @@
1
1
  function releasesUrl({ owner, repo }) {
2
2
  return `https://api.github.com/repos/${owner}/${repo}/releases`;
3
3
  }
4
+ function githubHeaders({ token }) {
5
+ return {
6
+ Accept: "application/vnd.github+json",
7
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
8
+ };
9
+ }
4
10
  /**
5
11
  * Fetches the latest GitHub release for a repo, along with its `.apk` asset.
6
12
  *
@@ -9,7 +15,7 @@ function releasesUrl({ owner, repo }) {
9
15
  */
10
16
  export async function fetchLatestRelease(ref) {
11
17
  const response = await fetch(`${releasesUrl(ref)}/latest`, {
12
- headers: { Accept: "application/vnd.github+json" },
18
+ headers: githubHeaders(ref),
13
19
  });
14
20
  if (!response.ok)
15
21
  throw new Error(`GitHub responded with ${String(response.status)}`);
@@ -29,9 +35,7 @@ export async function fetchLatestRelease(ref) {
29
35
  */
30
36
  export async function fetchReleaseHistory(ref) {
31
37
  const { limit = 10 } = ref;
32
- const response = await fetch(`${releasesUrl(ref)}?per_page=${String(limit)}`, {
33
- headers: { Accept: "application/vnd.github+json" },
34
- });
38
+ const response = await fetch(`${releasesUrl(ref)}?per_page=${String(limit)}`, { headers: githubHeaders(ref) });
35
39
  if (!response.ok)
36
40
  throw new Error(`GitHub responded with ${String(response.status)}`);
37
41
  const data = (await response.json());
@@ -0,0 +1,6 @@
1
+ export * from "./compareVersions.js";
2
+ export * from "./downloadAndInstallApk.js";
3
+ export * from "./githubReleases.js";
4
+ export * from "./isUpdateAvailable.js";
5
+ export * from "./parseChangelogNotes.js";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // Convenience barrel re-exporting everything below one import path. Deep
2
+ // imports (`@forthtilliath/expo-release-updates/downloadAndInstallApk`, etc.)
3
+ // remain the recommended default for apps that want to avoid pulling in the
4
+ // Expo peer deps they don't have installed — see the README.
5
+ export * from "./compareVersions.js";
6
+ export * from "./downloadAndInstallApk.js";
7
+ export * from "./githubReleases.js";
8
+ export * from "./isUpdateAvailable.js";
9
+ export * from "./parseChangelogNotes.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Whether `latestVersion` is strictly newer than `currentVersion`.
3
+ *
4
+ * Thin, named wrapper around `compareVersions` for the common "should I show
5
+ * the update prompt?" check, so call sites read as intent rather than a bare
6
+ * `compareVersions(a, b) > 0` comparison repeated at every call site.
7
+ *
8
+ * @example
9
+ * isUpdateAvailable("1.2.0", "1.10.0"); // true
10
+ * isUpdateAvailable("1.2.0", "1.2.0"); // false
11
+ */
12
+ export declare function isUpdateAvailable(currentVersion: string, latestVersion: string): boolean;
13
+ //# sourceMappingURL=isUpdateAvailable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isUpdateAvailable.d.ts","sourceRoot":"","sources":["../src/isUpdateAvailable.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,cAAc,EAAE,MAAM,EACtB,aAAa,EAAE,MAAM,GACpB,OAAO,CAET"}
@@ -0,0 +1,15 @@
1
+ import { compareVersions } from "./compareVersions.js";
2
+ /**
3
+ * Whether `latestVersion` is strictly newer than `currentVersion`.
4
+ *
5
+ * Thin, named wrapper around `compareVersions` for the common "should I show
6
+ * the update prompt?" check, so call sites read as intent rather than a bare
7
+ * `compareVersions(a, b) > 0` comparison repeated at every call site.
8
+ *
9
+ * @example
10
+ * isUpdateAvailable("1.2.0", "1.10.0"); // true
11
+ * isUpdateAvailable("1.2.0", "1.2.0"); // false
12
+ */
13
+ export function isUpdateAvailable(currentVersion, latestVersion) {
14
+ return compareVersions(latestVersion, currentVersion) > 0;
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forthtilliath/expo-release-updates",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -8,15 +8,16 @@
8
8
  },
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/Forthtilliath/turborepo.git",
11
+ "url": "https://github.com/Forthtilliath/forthtilliath-packages.git",
12
12
  "directory": "packages/expo-release-updates"
13
13
  },
14
- "homepage": "https://github.com/Forthtilliath/turborepo/tree/main/packages/expo-release-updates#readme",
15
- "bugs": "https://github.com/Forthtilliath/turborepo/issues",
14
+ "homepage": "https://github.com/Forthtilliath/forthtilliath-packages/tree/main/packages/expo-release-updates#readme",
15
+ "bugs": "https://github.com/Forthtilliath/forthtilliath-packages/issues",
16
16
  "files": [
17
17
  "dist"
18
18
  ],
19
19
  "exports": {
20
+ ".": "./dist/index.js",
20
21
  "./*": "./dist/*.js"
21
22
  },
22
23
  "peerDependencies": {
@@ -25,11 +26,11 @@
25
26
  },
26
27
  "devDependencies": {
27
28
  "eslint": "10.7.0",
28
- "expo-file-system": "~57.0.1",
29
+ "expo-file-system": "~57.0.2",
29
30
  "expo-intent-launcher": "~57.0.1",
30
31
  "typescript": "^5.9.3",
31
32
  "vitest": "^4.1.10",
32
- "@forthtilliath/eslint-config": "0.0.0",
33
+ "@forthtilliath/eslint-config": "0.4.2",
33
34
  "@forthtilliath/typescript-config": "0.0.0"
34
35
  },
35
36
  "scripts": {