@renbostudios/edge-ota 0.3.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.
Files changed (3) hide show
  1. package/README.md +313 -11
  2. package/dist/index.js +113 -4
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -1,26 +1,328 @@
1
1
  # @renbostudios/edge-ota CLI
2
2
 
3
- Zero-SDK, self-hostable OTA updates for Expo.
3
+ Zero-SDK, self-hostable OTA update CLI for Expo React Native. Push signed JavaScript bundles to any EdgeOTA server.
4
4
 
5
- See the [main README](../../README.md) for full documentation.
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @renbostudios/edge-ota
9
+ ```
10
+
11
+ Requires Node.js 18+ and an Expo project with `expo-updates` installed.
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # 1. Log in to your EdgeOTA account
17
+ edge-ota login
18
+
19
+ # 2. Register your project and generate signing keys
20
+ edge-ota init
21
+
22
+ # 3. Rebuild your native app (REQUIRED — see below)
23
+ npx expo prebuild --clean
24
+ npx expo run:ios
25
+ npx expo run:android
26
+
27
+ # 4. Push an OTA update
28
+ edge-ota push
29
+ ```
30
+
31
+ ---
6
32
 
7
33
  ## Commands
8
34
 
35
+ ### `edge-ota login`
36
+
37
+ Authenticate with your EdgeOTA account. Credentials are stored globally at `~/.config/edge-ota/config.json`.
38
+
39
+ ```bash
40
+ edge-ota login
41
+ edge-ota login --server https://my-server.com # custom server
9
42
  ```
10
- edge-ota login # Log in globally
11
- edge-ota logout # Log out
12
- edge-ota init # Set up a new project
13
- edge-ota push # Publish an OTA update
14
- edge-ota status # View recent deployments
15
- edge-ota keygen # Generate a new key pair
43
+
44
+ You'll be prompted for your email and password. The session token is saved globally — no per-project `.env` files needed.
45
+
46
+ ### `edge-ota logout`
47
+
48
+ Remove stored credentials.
49
+
50
+ ```bash
51
+ edge-ota logout
16
52
  ```
17
53
 
18
- ## Install
54
+ ### `edge-ota init`
55
+
56
+ Register your project on the EdgeOTA server, generate an ECDSA P-256 signing key pair, and configure `app.json` with the correct update URL.
19
57
 
20
58
  ```bash
21
- npm install -g @renbostudios/edge-ota
59
+ edge-ota init
60
+ edge-ota init --server https://my-server.com # override server
61
+ ```
62
+
63
+ **What it does:**
64
+
65
+ 1. Fetches your existing projects from the server (if any)
66
+ 2. Lets you select an existing project or create a new one
67
+ 3. Generates an ECDSA P-256 key pair
68
+ 4. Registers the project on the server with your public key
69
+ 5. Saves the private key to `~/.config/edge-ota/keys/<projectId>.key`
70
+ 6. Updates your `app.json` with:
71
+ - `expo.updates.url` — points to your EdgeOTA server
72
+ - `expo.updates.checkAutomatically` — set to `"ON_LOAD"`
73
+ - `expo.updates.fallbackToCacheTimeout` — set to `30000`
74
+ - `expo.updates.requestHeaders` — sets `expo-channel-name` to `"production"`
75
+ - `expo.extra.edgeOtaServer` — the server URL for manual override
76
+ - `expo.extra.edgeOtaPublicKey` — the generated public key
77
+
78
+ **After init, you MUST rebuild your native app.** The CLI will print a prominent warning with instructions. See the [Rebuild Required](#rebuild-required) section below.
79
+
80
+ ### `edge-ota push`
81
+
82
+ Export your Expo bundle and publish an OTA update to the server.
83
+
84
+ ```bash
85
+ edge-ota push # default: all platforms, production channel
86
+ edge-ota push --channel staging # deploy to staging
87
+ edge-ota push --platform ios # iOS only
88
+ edge-ota push --platform android # Android only
89
+ edge-ota push --skip-export # skip expo export, use existing ./dist
90
+ edge-ota push --dry-run # build and sign, but don't upload
91
+ ```
92
+
93
+ **What it does:**
94
+
95
+ 1. Runs `npx expo export` to create the JavaScript bundle (unless `--skip-export`)
96
+ 2. Collects all assets from `./dist`
97
+ 3. For each target platform (ios/android):
98
+ - Locates the platform-specific bundle
99
+ - Signs the payload with your ECDSA private key
100
+ - Uploads the bundle + signature to the server
101
+ 4. The server verifies the signature before storing the update
102
+
103
+ **How it reads your config:**
104
+
105
+ The CLI reads everything from `app.json` — no flags required for standard deploys:
106
+ - **Server URL** — from `expo.updates.url` (or `expo.extra.edgeOtaServer`)
107
+ - **Project ID** — parsed from the updates URL
108
+ - **Runtime version** — from `expo.runtimeVersion` (string or policy object)
109
+ - **Private key** — from `~/.config/edge-ota/keys/<projectId>.key`
110
+
111
+ ### `edge-ota status`
112
+
113
+ List recent deployments for the current project.
114
+
115
+ ```bash
116
+ edge-ota status
117
+ edge-ota status --limit 20 # show more releases
118
+ ```
119
+
120
+ ### `edge-ota keygen`
121
+
122
+ Generate a new ECDSA P-256 key pair and print it to stdout. Does not write any files.
123
+
124
+ ```bash
125
+ edge-ota keygen
126
+ ```
127
+
128
+ Useful for manual key rotation or generating keys for server-side signing.
129
+
130
+ ---
131
+
132
+ ## Rebuild Required
133
+
134
+ After running `edge-ota init`, you **must** rebuild your native app. The `expo-updates` native module reads its configuration (server URL, runtime version) from the **native binary**, not from `app.json` at runtime.
135
+
136
+ ```bash
137
+ # Regenerate native projects with fresh config
138
+ npx expo prebuild --clean
139
+
140
+ # Rebuild
141
+ npx expo run:ios
142
+ npx expo run:android
143
+
144
+ # Or via EAS
145
+ eas build --profile production
146
+ ```
147
+
148
+ **If you skip this step**, the app will fail with:
149
+
150
+ ```
151
+ Failed to check for update
152
+ ```
153
+
154
+ The native module has no valid server URL to reach because it was never embedded in the binary.
155
+
156
+ You must also rebuild after any change to `expo.updates.url` or `expo.runtimeVersion` in `app.json`.
157
+
158
+ ---
159
+
160
+ ## How It Works
161
+
162
+ ```
163
+ Developer Machine EdgeOTA Server User's Phone
164
+ ───────────────── ────────────── ────────────
165
+ edge-ota push ─────────────► POST /api/updates
166
+ (ECDSA verified)
167
+ stores bundle
168
+ ◄──────────── GET /api/updates
169
+ (expo-updates SDK)
170
+ ────────────► multipart manifest
171
+ ◄──────────── GET /api/assets/:hash
172
+ download bundle
173
+ reload app ✓
174
+ ```
175
+
176
+ 1. `edge-ota push` runs `expo export` and uploads the Hermes bundle + ECDSA signature to your server
177
+ 2. The server verifies the ECDSA P-256 signature before storing anything
178
+ 3. When users open the app, `expo-updates` checks your server for a new manifest
179
+ 4. The server responds with a signed multipart manifest (Expo Updates Protocol v1)
180
+ 5. `expo-updates` downloads and applies the update automatically
181
+
182
+ ---
183
+
184
+ ## What `init` Writes to `app.json`
185
+
186
+ After running `edge-ota init`, your `app.json` will contain:
187
+
188
+ ```json
189
+ {
190
+ "expo": {
191
+ "updates": {
192
+ "url": "https://your-server.com/api/projects/<projectId>/updates",
193
+ "checkAutomatically": "ON_LOAD",
194
+ "fallbackToCacheTimeout": 30000,
195
+ "requestHeaders": {
196
+ "expo-channel-name": "production"
197
+ }
198
+ },
199
+ "runtimeVersion": "1.0.0",
200
+ "extra": {
201
+ "edgeOtaServer": "https://your-server.com",
202
+ "edgeOtaPublicKey": "-----BEGIN PUBLIC KEY-----\n..."
203
+ }
204
+ }
205
+ }
206
+ ```
207
+
208
+ The `runtimeVersion` field is **not modified** by the CLI — it must already exist in your `app.json`. It supports both static strings and policy objects:
209
+
210
+ ```json
211
+ // Static (recommended)
212
+ "runtimeVersion": "1.0.0"
213
+
214
+ // Policy-based (auto-increments with expo.version)
215
+ "runtimeVersion": { "policy": "appVersion" }
216
+ ```
217
+
218
+ ---
219
+
220
+ ## Global Storage
221
+
222
+ All credentials and keys are stored outside your project directory:
223
+
224
+ | File | Contents |
225
+ |---|---|
226
+ | `~/.config/edge-ota/config.json` | Auth token, email, default server URL |
227
+ | `~/.config/edge-ota/keys/<projectId>.key` | Private ECDSA signing key (per-project) |
228
+ | `~/.config/edge-ota/update-check.json` | Auto-update check timestamp |
229
+
230
+ Nothing sensitive is ever stored in your project repository.
231
+
232
+ ---
233
+
234
+ ## Environment Variables
235
+
236
+ | Variable | Purpose |
237
+ |---|---|
238
+ | `EDGE_OTA_TOKEN` | Override the auth token (useful for CI/CD) |
239
+ | `EDGE_OTA_UPDATING` | Internal — prevents recursive auto-updates |
240
+
241
+ ---
242
+
243
+ ## Auto-Update
244
+
245
+ The CLI checks for newer versions on npm every 15 minutes. If a newer version is found, it auto-installs globally. This check happens silently in the background and never blocks command execution.
246
+
247
+ To disable: set `EDGE_OTA_UPDATING=true` in your environment.
248
+
249
+ ---
250
+
251
+ ## Security
252
+
253
+ - All bundles are signed with ECDSA P-256 before upload
254
+ - The server rejects any bundle with an invalid or missing signature
255
+ - Private keys are stored with `0600` permissions (owner read/write only)
256
+ - Session tokens expire after 24 hours
257
+ - Private keys never leave your machine
258
+
259
+ ---
260
+
261
+ ## Server Setup
262
+
263
+ The EdgeOTA server is a standard Node.js/Express app. You can self-host it or use the managed cloud service.
264
+
265
+ ### Docker
266
+
267
+ ```bash
268
+ docker pull renbostudios/edge-ota-server:latest
269
+ docker run -p 3000:3000 -e DATABASE_URL=... renbostudios/edge-ota-server
270
+ ```
271
+
272
+ ### Docker Compose
273
+
274
+ ```bash
275
+ git clone https://github.com/renbostudios/edge-ota-cli
276
+ cd edge-ota
277
+ docker-compose up -d
278
+ ```
279
+
280
+ ### Manual
281
+
282
+ ```bash
283
+ git clone https://github.com/renbostudios/edge-ota-cli
284
+ cd edge-ota
285
+ pnpm install
286
+ pnpm --filter @renbostudios/edge-ota-server-node dev
287
+ ```
288
+
289
+ ---
290
+
291
+ ## Troubleshooting
292
+
293
+ ### "Failed to check for update" on app launch
294
+
295
+ The `expo-updates` native module can't reach your server because the URL was never embedded in the binary. Fix: rebuild the native app after `edge-ota init`.
296
+
297
+ ```bash
298
+ npx expo prebuild --clean
299
+ npx expo run:ios
300
+ npx expo run:android
22
301
  ```
23
302
 
303
+ ### Updates work on iOS but not Android
304
+
305
+ The Android native project may still have the default Expo Updates URL. Fix: regenerate and rebuild.
306
+
307
+ ```bash
308
+ npx expo prebuild --clean
309
+ npx expo run:android
310
+ ```
311
+
312
+ Verify by checking `android/app/src/main/AndroidManifest.xml` for the `EXPO_UPDATE_URL` meta-data entry — it should point to your EdgeOTA server.
313
+
314
+ ### Updates stopped working after a version bump
315
+
316
+ If `runtimeVersion` uses the `appVersion` policy, every version bump changes the runtime version. Push a new update after bumping, or switch to a static string.
317
+
318
+ ### "No signing key found" error on push
319
+
320
+ Run `edge-ota init` to generate keys, or use `edge-ota keygen` to generate a new pair and register it manually.
321
+
24
322
  ---
25
323
 
26
- by **[Renbo Studios](https://renbostudios.com)** — Mobile App Development Studio
324
+ ## Built by Renbo Studios
325
+
326
+ [renbostudios.com](https://renbostudios.com) — Mobile App Development Studio
327
+
328
+ MIT License
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * edge-ota keygen — Generate a new ECDSA key pair (prints to stdout)
14
14
  */
15
15
  import { Command } from "commander";
16
- import { spawn } from "child_process";
16
+ import { execSync, spawn } from "child_process";
17
17
  import fs from "fs";
18
18
  import os from "os";
19
19
  import path from "path";
@@ -154,6 +154,70 @@ try {
154
154
  }
155
155
  }
156
156
  catch { /* fallback */ }
157
+ // ─── Auto Update Check ────────────────────────────────────────────────────────
158
+ const UPDATE_CHECK_FILE = path.join(GLOBAL_CONFIG_DIR, "update-check.json");
159
+ function isNewerVersion(current, latest) {
160
+ const cParts = current.split(".").map(Number);
161
+ const lParts = latest.split(".").map(Number);
162
+ for (let i = 0; i < 3; i++) {
163
+ const cNum = cParts[i] || 0;
164
+ const lNum = lParts[i] || 0;
165
+ if (lNum > cNum)
166
+ return true;
167
+ if (lNum < cNum)
168
+ return false;
169
+ }
170
+ return false;
171
+ }
172
+ async function checkAndAutoUpdate() {
173
+ if (process.env.EDGE_OTA_UPDATING === "true")
174
+ return;
175
+ try {
176
+ const now = Date.now();
177
+ if (fs.existsSync(UPDATE_CHECK_FILE)) {
178
+ try {
179
+ const cache = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, "utf-8"));
180
+ // Only check npm once every 15 minutes to keep CLI commands extremely fast
181
+ if (now - (cache.lastChecked || 0) < 15 * 60 * 1000) {
182
+ return;
183
+ }
184
+ }
185
+ catch { /* ignore */ }
186
+ }
187
+ // Save timestamp immediately to prevent concurrent requests
188
+ fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
189
+ fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ lastChecked: now }));
190
+ // Fetch latest version from registry with a short 1s timeout
191
+ const controller = new AbortController();
192
+ const timerId = setTimeout(() => controller.abort(), 1000);
193
+ const res = await fetch("https://registry.npmjs.org/@renbostudios/edge-ota/latest", {
194
+ signal: controller.signal
195
+ });
196
+ clearTimeout(timerId);
197
+ if (!res.ok)
198
+ return;
199
+ const data = (await res.json());
200
+ const latestVersion = data.version;
201
+ if (latestVersion && isNewerVersion(packageVersion, latestVersion)) {
202
+ console.log(`\n ${c.yellow}${c.reset} New version of ${c.bold}edge-ota${c.reset} available: ${c.green}${latestVersion}${c.reset} (current: ${c.dim}${packageVersion}${c.reset})`);
203
+ console.log(` ${c.blue}➜${c.reset} Auto-updating globally...`);
204
+ try {
205
+ execSync("npm install -g @renbostudios/edge-ota", {
206
+ stdio: "inherit",
207
+ env: { ...process.env, EDGE_OTA_UPDATING: "true" }
208
+ });
209
+ console.log(` ${c.green}✓${c.reset} Successfully auto-updated to ${c.bold}${latestVersion}${c.reset}!\n`);
210
+ }
211
+ catch (err) {
212
+ console.error(` ${c.red}✗${c.reset} Auto-update failed: ${err.message}`);
213
+ console.error(` Please update manually: ${c.cyan}npm install -g @renbostudios/edge-ota${c.reset}\n`);
214
+ }
215
+ }
216
+ }
217
+ catch {
218
+ // Fail silently so CLI works offline
219
+ }
220
+ }
157
221
  // ─── Banner ───────────────────────────────────────────────────────────────────
158
222
  function printBanner() {
159
223
  const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
@@ -182,12 +246,32 @@ function readAppJson(cwd) {
182
246
  }
183
247
  const expo = data?.expo ?? {};
184
248
  // ── Runtime version ──
185
- const runtimeVersion = expo.runtimeVersion;
249
+ let runtimeVersion = expo.runtimeVersion;
186
250
  if (!runtimeVersion) {
187
251
  console.error(` ${c.red}✗${c.reset} ${c.bold}expo.runtimeVersion${c.reset} is not set in app.json`);
188
252
  console.error(` Add: ${c.dim}"runtimeVersion": "1.0.0"${c.reset} under the expo key.\n`);
189
253
  process.exit(1);
190
254
  }
255
+ // Resolve runtimeVersion object (e.g. policy helper) to string
256
+ if (typeof runtimeVersion === "object" && runtimeVersion !== null) {
257
+ const policy = runtimeVersion.policy;
258
+ if (policy === "appVersion") {
259
+ runtimeVersion = expo.version;
260
+ }
261
+ else if (policy === "sdkVersion") {
262
+ runtimeVersion = expo.sdkVersion;
263
+ }
264
+ else {
265
+ runtimeVersion = expo.version || expo.sdkVersion;
266
+ }
267
+ }
268
+ if (!runtimeVersion || typeof runtimeVersion !== "string") {
269
+ console.error(` ${c.red}✗${c.reset} Could not resolve ${c.bold}expo.runtimeVersion${c.reset} to a valid string in app.json`);
270
+ if (typeof expo.runtimeVersion === "object") {
271
+ console.error(` Since you are using a policy, please ensure either ${c.bold}expo.version${c.reset} or ${c.bold}expo.sdkVersion${c.reset} is defined.\n`);
272
+ }
273
+ process.exit(1);
274
+ }
191
275
  // ── Updates URL ──
192
276
  const updatesUrl = expo?.updates?.url ?? "";
193
277
  if (!updatesUrl) {
@@ -507,6 +591,7 @@ program
507
591
  console.log(` ${c.green}✓${c.reset} Logged in as ${c.bold}${data.email}${c.reset}`);
508
592
  console.log(` ${c.dim}credentials saved to ${GLOBAL_CONFIG_FILE}${c.reset}`);
509
593
  console.log(sep + "\n");
594
+ process.exit(0);
510
595
  }
511
596
  catch (e) {
512
597
  spin.fail(`Connection failed: ${e.message}`);
@@ -529,6 +614,7 @@ program
529
614
  else {
530
615
  console.log(` ${c.dim}Already logged out.${c.reset}\n`);
531
616
  }
617
+ process.exit(0);
532
618
  });
533
619
  // ──────────────────────────────────────────────────────────────────────────────
534
620
  // edge-ota init
@@ -644,8 +730,27 @@ program
644
730
  console.log(` project ${c.dim}${projectId}${c.reset}`);
645
731
  console.log(` server ${c.dim}${serverUrl}${c.reset}`);
646
732
  console.log(` key file ${c.dim}${path.join(GLOBAL_KEYS_DIR, `${projectId}.key`)}${c.reset}`);
733
+ console.log(sep);
734
+ console.log(`\n${c.bold}${c.yellow} ⚠ REBUILD REQUIRED${c.reset}`);
735
+ console.log(sep);
736
+ console.log(` ${c.bold}Your native app MUST be rebuilt for OTA updates${c.reset}`);
737
+ console.log(` ${c.bold}to work.${c.reset} The server URL is baked into the native`);
738
+ console.log(` binary at build time — not read from app.json.`);
739
+ console.log();
740
+ console.log(` Run these commands in order:`);
741
+ console.log();
742
+ console.log(` ${c.cyan}npx expo prebuild --clean${c.reset}`);
743
+ console.log(` ${c.cyan}npx expo run:ios${c.reset}`);
744
+ console.log(` ${c.cyan}npx expo run:android${c.reset}`);
745
+ console.log();
746
+ console.log(` Or via EAS:`);
747
+ console.log(` ${c.cyan}eas build --profile production${c.reset}`);
748
+ console.log();
749
+ console.log(` ${c.red}Skipping this WILL cause "Failed to check for${c.reset}`);
750
+ console.log(` ${c.red}update" errors on app launch.${c.reset}`);
647
751
  console.log(sep + "\n");
648
- console.log(` ${c.dim}Next step:${c.reset} run ${c.cyan}edge-ota push${c.reset} to publish your first update.\n`);
752
+ console.log(` Then run ${c.cyan}edge-ota push${c.reset} to publish your first update.\n`);
753
+ process.exit(0);
649
754
  }
650
755
  });
651
756
  // ──────────────────────────────────────────────────────────────────────────────
@@ -805,6 +910,7 @@ program
805
910
  console.log(`\n${sep}`);
806
911
  console.log(` ${c.green}✓${c.reset} ${c.bold}done${c.reset} ${c.dim}update will be applied on next OTA sync${c.reset}`);
807
912
  console.log(`${sep}\n`);
913
+ process.exit(0);
808
914
  });
809
915
  // ──────────────────────────────────────────────────────────────────────────────
810
916
  // edge-ota status
@@ -839,7 +945,7 @@ program
839
945
  spin.stop();
840
946
  if (!releases.length) {
841
947
  console.log(` ${c.dim}No releases found.${c.reset}\n`);
842
- return;
948
+ process.exit(0);
843
949
  }
844
950
  const limit = parseInt(options.limit);
845
951
  const rows = releases.slice(0, limit).map((r) => ({
@@ -850,6 +956,7 @@ program
850
956
  Created: r.created_at ? new Date(r.created_at).toLocaleString() : "—",
851
957
  }));
852
958
  console.table(rows);
959
+ process.exit(0);
853
960
  });
854
961
  // ──────────────────────────────────────────────────────────────────────────────
855
962
  // edge-ota keygen
@@ -868,5 +975,7 @@ program
868
975
  console.log(` ${c.bold}public key${c.reset} ${c.dim}paste into dashboard → Settings → General${c.reset}`);
869
976
  console.log(sep);
870
977
  console.log(keys.publicKey);
978
+ process.exit(0);
871
979
  });
980
+ await checkAndAutoUpdate();
872
981
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@renbostudios/edge-ota",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "preferGlobal": true,
5
5
  "description": "Zero-SDK, self-hostable OTA update CLI for Expo — push signed JS bundles to any EdgeOTA server",
6
6
  "license": "MIT",
7
7
  "author": "EdgeOTA Contributors",
8
- "homepage": "https://github.com/renbostudios/edge-ota",
8
+ "homepage": "https://github.com/renbostudios/edge-ota-cli",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/renbostudios/edge-ota.git"
11
+ "url": "https://github.com/renbostudios/edge-ota-cli.git"
12
12
  },
13
13
  "bugs": {
14
- "url": "https://github.com/renbostudios/edge-ota/issues"
14
+ "url": "https://github.com/renbostudios/edge-ota-cli/issues"
15
15
  },
16
16
  "keywords": [
17
17
  "expo",