@renbostudios/edge-ota 0.3.1 → 0.4.2

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/dist/index.js CHANGED
@@ -1,17 +1,6 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * edge-ota CLI — Zero-SDK, self-hostable OTA updates for Expo
4
- * by Renbo Studios — Mobile App Development Studio
5
- * https://renbostudios.com
6
- *
7
- * Commands:
8
- * edge-ota login — Authenticate and save credentials globally
9
- * edge-ota logout — Remove stored credentials
10
- * edge-ota init — Register project, generate signing keys, configure app.json
11
- * edge-ota push — Export bundle and publish an OTA update
12
- * edge-ota status — List recent deployments for this project
13
- * edge-ota keygen — Generate a new ECDSA key pair (prints to stdout)
14
- */
2
+
3
+ // src/index.ts
15
4
  import { Command } from "commander";
16
5
  import { execSync, spawn } from "child_process";
17
6
  import fs from "fs";
@@ -19,919 +8,1230 @@ import os from "os";
19
8
  import path from "path";
20
9
  import readline from "readline";
21
10
  import { fileURLToPath } from "url";
22
- import { generateECDSAKeyPair, signPayload, sha256Hex } from "./core/index.js";
23
- const __filename = fileURLToPath(import.meta.url);
24
- const __dirname = path.dirname(__filename);
25
- // ─── Global Config Paths ─────────────────────────────────────────────────────
26
- const GLOBAL_CONFIG_DIR = path.join(os.homedir(), ".config", "edge-ota");
27
- const GLOBAL_CONFIG_FILE = path.join(GLOBAL_CONFIG_DIR, "config.json");
28
- const GLOBAL_KEYS_DIR = path.join(GLOBAL_CONFIG_DIR, "keys");
11
+
12
+ // ../../packages/core/dist/crypto.js
13
+ async function generateECDSAKeyPair() {
14
+ const keyPair = await globalThis.crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
15
+ const privateKeyDer = await globalThis.crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
16
+ const publicKeyDer = await globalThis.crypto.subtle.exportKey("spki", keyPair.publicKey);
17
+ return {
18
+ privateKey: derToPem(privateKeyDer, "PRIVATE KEY"),
19
+ publicKey: derToPem(publicKeyDer, "PUBLIC KEY")
20
+ };
21
+ }
22
+ async function signPayload(payload, privateKeyPem) {
23
+ const privateKeyDer = pemToDer(privateKeyPem, "PRIVATE KEY");
24
+ const privateKey = await globalThis.crypto.subtle.importKey("pkcs8", privateKeyDer, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
25
+ const data = new TextEncoder().encode(payload);
26
+ const sigBuffer = await globalThis.crypto.subtle.sign({ name: "ECDSA", hash: { name: "SHA-256" } }, privateKey, data);
27
+ return arrayBufferToBase64(sigBuffer);
28
+ }
29
+ async function sha256Hex(buffer) {
30
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer);
31
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
32
+ }
33
+ function derToPem(der, label) {
34
+ const base64 = arrayBufferToBase64(der);
35
+ const lines = base64.match(/.{1,64}/g) || [];
36
+ return `-----BEGIN ${label}-----
37
+ ${lines.join("\n")}
38
+ -----END ${label}-----`;
39
+ }
40
+ function pemToDer(pem, label) {
41
+ const cleanPem = pem.replace(`-----BEGIN ${label}-----`, "").replace(`-----END ${label}-----`, "").replace(/\s+/g, "");
42
+ return base64ToArrayBuffer(cleanPem);
43
+ }
44
+ function arrayBufferToBase64(buffer) {
45
+ if (typeof Buffer !== "undefined") {
46
+ return Buffer.from(buffer).toString("base64");
47
+ }
48
+ const bytes = new Uint8Array(buffer);
49
+ let binary = "";
50
+ const chunkSize = 32768;
51
+ for (let i = 0; i < bytes.length; i += chunkSize) {
52
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize));
53
+ }
54
+ return globalThis.btoa(binary);
55
+ }
56
+ function base64ToArrayBuffer(base64) {
57
+ if (typeof Buffer !== "undefined") {
58
+ const buf = Buffer.from(base64, "base64");
59
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
60
+ }
61
+ const binary = globalThis.atob(base64);
62
+ const bytes = new Uint8Array(binary.length);
63
+ for (let i = 0; i < binary.length; i++) {
64
+ bytes[i] = binary.charCodeAt(i);
65
+ }
66
+ return bytes.buffer;
67
+ }
68
+
69
+ // src/index.ts
70
+ var __filename = fileURLToPath(import.meta.url);
71
+ var __dirname = path.dirname(__filename);
72
+ var GLOBAL_CONFIG_DIR = path.join(os.homedir(), ".config", "edge-ota");
73
+ var GLOBAL_CONFIG_FILE = path.join(GLOBAL_CONFIG_DIR, "config.json");
74
+ var GLOBAL_KEYS_DIR = path.join(GLOBAL_CONFIG_DIR, "keys");
29
75
  function loadGlobalConfig() {
30
- try {
31
- if (fs.existsSync(GLOBAL_CONFIG_FILE)) {
32
- return JSON.parse(fs.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
33
- }
76
+ try {
77
+ if (fs.existsSync(GLOBAL_CONFIG_FILE)) {
78
+ return JSON.parse(fs.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
34
79
  }
35
- catch { /* ignore */ }
36
- return null;
80
+ } catch {
81
+ }
82
+ return null;
37
83
  }
38
84
  function saveGlobalConfig(config) {
39
- fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
40
- fs.writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
85
+ fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
86
+ fs.writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
41
87
  }
42
88
  function clearGlobalConfig() {
43
- try {
44
- fs.rmSync(GLOBAL_CONFIG_FILE);
45
- }
46
- catch { /* ignore */ }
89
+ try {
90
+ fs.rmSync(GLOBAL_CONFIG_FILE);
91
+ } catch {
92
+ }
47
93
  }
48
94
  function loadPrivateKey(projectId) {
49
- // 1. Global key store (new location)
50
- const globalKeyPath = path.join(GLOBAL_KEYS_DIR, `${projectId}.key`);
51
- if (fs.existsSync(globalKeyPath)) {
52
- return fs.readFileSync(globalKeyPath, "utf-8").trim();
53
- }
54
- // 2. Legacy local file (backward compat with deprecation notice)
55
- const localKeyPath = path.resolve(process.cwd(), ".edge-ota.private.key");
56
- if (fs.existsSync(localKeyPath)) {
57
- spin.stop();
58
- console.log(` ${c.yellow}⚠${c.reset} Using legacy ${c.dim}.edge-ota.private.key${c.reset} in project directory.`);
59
- console.log(` Move it to ${c.dim}${globalKeyPath}${c.reset} and delete the local copy.`);
60
- return fs.readFileSync(localKeyPath, "utf-8").trim();
61
- }
62
- return null;
95
+ const globalKeyPath = path.join(GLOBAL_KEYS_DIR, `${projectId}.key`);
96
+ if (fs.existsSync(globalKeyPath)) {
97
+ return fs.readFileSync(globalKeyPath, "utf-8").trim();
98
+ }
99
+ const localKeyPath = path.resolve(process.cwd(), ".edge-ota.private.key");
100
+ if (fs.existsSync(localKeyPath)) {
101
+ spin.stop();
102
+ console.log(` ${c.yellow}\u26A0${c.reset} Using legacy ${c.dim}.edge-ota.private.key${c.reset} in project directory.`);
103
+ console.log(` Move it to ${c.dim}${globalKeyPath}${c.reset} and delete the local copy.`);
104
+ return fs.readFileSync(localKeyPath, "utf-8").trim();
105
+ }
106
+ return null;
63
107
  }
64
108
  function savePrivateKey(projectId, key) {
65
- fs.mkdirSync(GLOBAL_KEYS_DIR, { recursive: true });
66
- const keyPath = path.join(GLOBAL_KEYS_DIR, `${projectId}.key`);
67
- fs.writeFileSync(keyPath, key, { mode: 0o600 });
109
+ fs.mkdirSync(GLOBAL_KEYS_DIR, { recursive: true });
110
+ const keyPath = path.join(GLOBAL_KEYS_DIR, `${projectId}.key`);
111
+ fs.writeFileSync(keyPath, key, { mode: 384 });
68
112
  }
69
- // ─── Auto-load .env ───────────────────────────────────────────────────────────
70
113
  try {
71
- const envPath = path.resolve(process.cwd(), ".env");
72
- if (fs.existsSync(envPath)) {
73
- for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
74
- const t = line.trim();
75
- if (!t || t.startsWith("#"))
76
- continue;
77
- const eq = t.indexOf("=");
78
- if (eq === -1)
79
- continue;
80
- const k = t.slice(0, eq).trim();
81
- const v = t.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
82
- if (!(k in process.env))
83
- process.env[k] = v;
84
- }
114
+ const envPath = path.resolve(process.cwd(), ".env");
115
+ if (fs.existsSync(envPath)) {
116
+ for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
117
+ const t = line.trim();
118
+ if (!t || t.startsWith("#")) continue;
119
+ const eq = t.indexOf("=");
120
+ if (eq === -1) continue;
121
+ const k = t.slice(0, eq).trim();
122
+ const v = t.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
123
+ if (!(k in process.env)) process.env[k] = v;
85
124
  }
125
+ }
126
+ } catch {
86
127
  }
87
- catch { /* silently skip */ }
88
- // ─── ANSI Colors ─────────────────────────────────────────────────────────────
89
- const c = {
90
- reset: "\x1b[0m",
91
- bold: "\x1b[1m",
92
- dim: "\x1b[2m",
93
- underline: "\x1b[4m",
94
- red: "\x1b[31m",
95
- green: "\x1b[32m",
96
- yellow: "\x1b[33m",
97
- blue: "\x1b[34m",
98
- cyan: "\x1b[36m",
99
- white: "\x1b[37m",
100
- gray: "\x1b[90m",
128
+ var c = {
129
+ reset: "\x1B[0m",
130
+ bold: "\x1B[1m",
131
+ dim: "\x1B[2m",
132
+ underline: "\x1B[4m",
133
+ red: "\x1B[31m",
134
+ green: "\x1B[32m",
135
+ yellow: "\x1B[33m",
136
+ blue: "\x1B[34m",
137
+ magenta: "\x1B[35m",
138
+ cyan: "\x1B[36m",
139
+ white: "\x1B[37m",
140
+ gray: "\x1B[90m"
101
141
  };
102
- // ─── Spinner ─────────────────────────────────────────────────────────────────
103
- const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
104
- const spin = (() => {
105
- let timer = null;
106
- let frame = 0;
107
- let label = "";
142
+ function printErrorBox(title, reason, solution) {
143
+ const sep = `${c.red}${"\u2500".repeat(56)}${c.reset}`;
144
+ console.error(`
145
+ ${sep}`);
146
+ console.error(` ${c.red}${c.bold}\u2716 ${title}${c.reset}`);
147
+ console.error(` ${c.dim}${reason}${c.reset}`);
148
+ if (solution) {
149
+ console.error();
150
+ console.error(` ${c.cyan}${c.bold}\u{1F4A1} How to fix:${c.reset}`);
151
+ const sols = Array.isArray(solution) ? solution : [solution];
152
+ for (const s of sols) {
153
+ console.error(` ${s}`);
154
+ }
155
+ }
156
+ console.error(`${sep}
157
+ `);
158
+ }
159
+ function printWarningBox(title, reason, tip) {
160
+ const sep = `${c.yellow}${"\u2500".repeat(56)}${c.reset}`;
161
+ console.warn(`
162
+ ${sep}`);
163
+ console.warn(` ${c.yellow}${c.bold}\u26A0 ${title}${c.reset}`);
164
+ console.warn(` ${c.dim}${reason}${c.reset}`);
165
+ if (tip) {
166
+ console.warn(` ${c.cyan}${tip}${c.reset}`);
167
+ }
168
+ console.warn(`${sep}
169
+ `);
170
+ }
171
+ function diagnoseNetworkError(err, serverUrl) {
172
+ const msg = err?.message || String(err);
173
+ if (msg.includes("ECONNREFUSED")) {
108
174
  return {
109
- start(text) {
110
- label = text;
111
- frame = 0;
112
- if (!process.stdout.isTTY) {
113
- process.stdout.write(` ${text}...\n`);
114
- return;
115
- }
116
- timer = setInterval(() => {
117
- const f = `${c.cyan}${FRAMES[frame % FRAMES.length]}${c.reset}`;
118
- process.stdout.write(`\r ${f} ${c.dim}${label}${c.reset} `);
119
- frame++;
120
- }, 80);
121
- },
122
- update(text) {
123
- label = text;
124
- },
125
- stop(successMsg) {
126
- if (timer) {
127
- clearInterval(timer);
128
- timer = null;
129
- }
130
- if (process.stdout.isTTY) {
131
- process.stdout.write("\r\x1b[K"); // clear line
132
- }
133
- if (successMsg) {
134
- console.log(` ${c.green}✓${c.reset} ${successMsg}`);
135
- }
136
- },
137
- fail(errMsg) {
138
- if (timer) {
139
- clearInterval(timer);
140
- timer = null;
141
- }
142
- if (process.stdout.isTTY)
143
- process.stdout.write("\r\x1b[K");
144
- console.error(` ${c.red}✗${c.reset} ${errMsg}`);
145
- }
175
+ title: "Cannot connect to EdgeOTA server",
176
+ reason: `Server at ${c.bold}${serverUrl}${c.reset}${c.dim} refused the connection (ECONNREFUSED).`,
177
+ solution: [
178
+ `Ensure your EdgeOTA server is running: ${c.bold}lsof -i :3020${c.reset} or check your server process.`,
179
+ `Verify the server URL in ${c.bold}app.json${c.reset} or specify ${c.cyan}-s <url>${c.reset}`,
180
+ `If using USB physical device debugging, run ${c.cyan}adb reverse tcp:3020 tcp:3020${c.reset}`
181
+ ]
146
182
  };
183
+ }
184
+ if (msg.includes("ETIMEDOUT") || msg.includes("timeout") || msg.includes("aborted")) {
185
+ return {
186
+ title: "Server request timed out",
187
+ reason: `Connection to ${c.bold}${serverUrl}${c.reset}${c.dim} exceeded timeout threshold.`,
188
+ solution: [
189
+ "Check your internet connection or local network routing.",
190
+ "Ensure your firewall or VPS security group permits traffic on this port."
191
+ ]
192
+ };
193
+ }
194
+ return {
195
+ title: "Network request failed",
196
+ reason: msg,
197
+ solution: [
198
+ `Check your server URL: ${c.bold}${serverUrl}${c.reset}`,
199
+ `Verify internet connectivity and try again.`
200
+ ]
201
+ };
202
+ }
203
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
204
+ var spin = /* @__PURE__ */ (() => {
205
+ let timer = null;
206
+ let frame = 0;
207
+ let label = "";
208
+ return {
209
+ start(text) {
210
+ label = text;
211
+ frame = 0;
212
+ if (!process.stdout.isTTY) {
213
+ process.stdout.write(` ${text}...
214
+ `);
215
+ return;
216
+ }
217
+ timer = setInterval(() => {
218
+ const f = `${c.cyan}${FRAMES[frame % FRAMES.length]}${c.reset}`;
219
+ process.stdout.write(`\r ${f} ${c.dim}${label}${c.reset} `);
220
+ frame++;
221
+ }, 80);
222
+ },
223
+ update(text) {
224
+ label = text;
225
+ },
226
+ stop(successMsg) {
227
+ if (timer) {
228
+ clearInterval(timer);
229
+ timer = null;
230
+ }
231
+ if (process.stdout.isTTY) {
232
+ process.stdout.write("\r\x1B[K");
233
+ }
234
+ if (successMsg) {
235
+ console.log(` ${c.green}\u2713${c.reset} ${successMsg}`);
236
+ }
237
+ },
238
+ fail(errMsg) {
239
+ if (timer) {
240
+ clearInterval(timer);
241
+ timer = null;
242
+ }
243
+ if (process.stdout.isTTY) process.stdout.write("\r\x1B[K");
244
+ console.error(` ${c.red}\u2717${c.reset} ${errMsg}`);
245
+ }
246
+ };
147
247
  })();
148
- // ─── Package version ─────────────────────────────────────────────────────────
149
- let packageVersion = "0.3.0";
248
+ var packageVersion = "0.4.0";
150
249
  try {
151
- const pkgPath = path.join(__dirname, "..", "package.json");
152
- if (fs.existsSync(pkgPath)) {
153
- packageVersion = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
154
- }
250
+ const pkgPath = path.join(__dirname, "..", "package.json");
251
+ if (fs.existsSync(pkgPath)) {
252
+ packageVersion = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
253
+ }
254
+ } catch {
155
255
  }
156
- catch { /* fallback */ }
157
- // ─── Auto Update Check ────────────────────────────────────────────────────────
158
- const UPDATE_CHECK_FILE = path.join(GLOBAL_CONFIG_DIR, "update-check.json");
256
+ var UPDATE_CHECK_FILE = path.join(GLOBAL_CONFIG_DIR, "update-check.json");
159
257
  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;
258
+ const cParts = current.split(".").map(Number);
259
+ const lParts = latest.split(".").map(Number);
260
+ for (let i = 0; i < 3; i++) {
261
+ const cNum = cParts[i] || 0;
262
+ const lNum = lParts[i] || 0;
263
+ if (lNum > cNum) return true;
264
+ if (lNum < cNum) return false;
265
+ }
266
+ return false;
171
267
  }
172
268
  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
- }
269
+ if (process.env.EDGE_OTA_UPDATING === "true") return;
270
+ try {
271
+ const now = Date.now();
272
+ if (fs.existsSync(UPDATE_CHECK_FILE)) {
273
+ try {
274
+ const cache = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, "utf-8"));
275
+ if (now - (cache.lastChecked || 0) < 15 * 60 * 1e3) {
276
+ return;
215
277
  }
278
+ } catch {
279
+ }
216
280
  }
217
- catch {
218
- // Fail silently so CLI works offline
281
+ fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
282
+ fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ lastChecked: now }));
283
+ const controller = new AbortController();
284
+ const timerId = setTimeout(() => controller.abort(), 1e3);
285
+ const res = await fetch("https://registry.npmjs.org/@renbostudios/edge-ota/latest", {
286
+ signal: controller.signal
287
+ });
288
+ clearTimeout(timerId);
289
+ if (!res.ok) return;
290
+ const data = await res.json();
291
+ const latestVersion = data.version;
292
+ if (latestVersion && isNewerVersion(packageVersion, latestVersion)) {
293
+ console.log(`
294
+ ${c.yellow}\uF069${c.reset} New version of ${c.bold}edge-ota${c.reset} available: ${c.green}${latestVersion}${c.reset} (current: ${c.dim}${packageVersion}${c.reset})`);
295
+ console.log(` ${c.blue}\u279C${c.reset} Auto-updating globally...`);
296
+ try {
297
+ execSync("npm install -g @renbostudios/edge-ota", {
298
+ stdio: "inherit",
299
+ env: { ...process.env, EDGE_OTA_UPDATING: "true" }
300
+ });
301
+ console.log(` ${c.green}\u2713${c.reset} Successfully auto-updated to ${c.bold}${latestVersion}${c.reset}!
302
+ `);
303
+ } catch (err) {
304
+ console.error(` ${c.red}\u2717${c.reset} Auto-update failed: ${err.message}`);
305
+ console.error(` Please update manually: ${c.cyan}npm install -g @renbostudios/edge-ota${c.reset}
306
+ `);
307
+ }
219
308
  }
309
+ } catch {
310
+ }
220
311
  }
221
- // ─── Banner ───────────────────────────────────────────────────────────────────
222
312
  function printBanner() {
223
- const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
224
- console.log(`\n${sep}`);
225
- console.log(` ${c.bold}${c.white}edge-ota${c.reset} ${c.dim}v${packageVersion} · Zero-SDK OTA for Expo${c.reset}`);
226
- console.log(` ${c.dim}by ${c.reset}${c.bold}Renbo Studios${c.reset}${c.dim} — Mobile App Development Studio${c.reset}`);
227
- console.log(` ${c.dim}renbostudios.com${c.reset}`);
228
- console.log(`${sep}\n`);
313
+ const sep = `${c.dim}${"\u2500".repeat(56)}${c.reset}`;
314
+ console.log(`
315
+ ${sep}`);
316
+ console.log(` ${c.bold}${c.white}edge-ota${c.reset} ${c.dim}v${packageVersion} \xB7 Zero-SDK OTA for Expo${c.reset}`);
317
+ console.log(` ${c.dim}by ${c.reset}${c.bold}Renbo Studios${c.reset}${c.dim} \u2014 Mobile App Development Studio${c.reset}`);
318
+ console.log(` ${c.dim}renbostudios.com${c.reset}`);
319
+ console.log(`${sep}
320
+ `);
229
321
  }
230
- // ─── app.json helpers ────────────────────────────────────────────────────────
231
- const DEFAULT_SERVER = "https://api.ota.renbo.site";
322
+ var DEFAULT_SERVER = "https://api.ota.renbo.site";
232
323
  function readAppJson(cwd) {
233
- const appJsonPath = path.resolve(cwd, "app.json");
234
- if (!fs.existsSync(appJsonPath)) {
235
- console.error(`\n ${c.red}✗${c.reset} ${c.bold}app.json not found${c.reset} in ${cwd}`);
236
- console.error(` Run this command from your Expo project root.\n`);
237
- process.exit(1);
238
- }
239
- let data;
240
- try {
241
- data = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
242
- }
243
- catch {
244
- console.error(` ${c.red}✗${c.reset} Failed to parse app.json`);
245
- process.exit(1);
246
- }
247
- const expo = data?.expo ?? {};
248
- // ── Runtime version ──
249
- const runtimeVersion = expo.runtimeVersion;
250
- if (!runtimeVersion) {
251
- console.error(` ${c.red}✗${c.reset} ${c.bold}expo.runtimeVersion${c.reset} is not set in app.json`);
252
- console.error(` Add: ${c.dim}"runtimeVersion": "1.0.0"${c.reset} under the expo key.\n`);
253
- process.exit(1);
254
- }
255
- // ── Updates URL ──
256
- const updatesUrl = expo?.updates?.url ?? "";
257
- if (!updatesUrl) {
258
- console.error(` ${c.red}✗${c.reset} ${c.bold}expo.updates.url${c.reset} is not configured in app.json`);
259
- console.error(` Run ${c.cyan}edge-ota init${c.reset} to set this up.\n`);
260
- process.exit(1);
324
+ const appJsonPath = path.resolve(cwd, "app.json");
325
+ if (!fs.existsSync(appJsonPath)) {
326
+ printErrorBox(
327
+ "app.json not found",
328
+ `No app.json file located in directory: ${cwd}`,
329
+ [
330
+ `Ensure you are running edge-ota commands from the ${c.bold}root directory${c.reset} of your Expo project.`,
331
+ `If you use dynamic config (${c.dim}app.config.js / app.config.ts${c.reset}), create a base ${c.bold}app.json${c.reset} file with your Expo configuration.`
332
+ ]
333
+ );
334
+ process.exit(1);
335
+ }
336
+ let data;
337
+ try {
338
+ data = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
339
+ } catch (parseErr) {
340
+ printErrorBox(
341
+ "Invalid app.json syntax",
342
+ `Failed to parse JSON in ${appJsonPath}: ${parseErr.message}`,
343
+ `Verify that app.json contains valid JSON syntax (no trailing commas, unclosed brackets, or unescaped quotes).`
344
+ );
345
+ process.exit(1);
346
+ }
347
+ const expo = data?.expo ?? {};
348
+ let runtimeVersion = expo.runtimeVersion;
349
+ if (!runtimeVersion) {
350
+ printErrorBox(
351
+ "Missing expo.runtimeVersion in app.json",
352
+ "Expo Updates requires a runtimeVersion to ensure native compatibility between the app binary and JS bundle.",
353
+ [
354
+ `Add a fixed version string in ${c.bold}app.json${c.reset} under ${c.dim}"expo"${c.reset}: ${c.cyan}"runtimeVersion": "1.0.0"${c.reset}`,
355
+ `Or use an Expo policy helper: ${c.cyan}"runtimeVersion": { "policy": "appVersion" }${c.reset}`
356
+ ]
357
+ );
358
+ process.exit(1);
359
+ }
360
+ if (typeof runtimeVersion === "object" && runtimeVersion !== null) {
361
+ const policy = runtimeVersion.policy;
362
+ if (policy === "appVersion") {
363
+ runtimeVersion = expo.version;
364
+ } else if (policy === "sdkVersion") {
365
+ runtimeVersion = expo.sdkVersion;
366
+ } else {
367
+ runtimeVersion = expo.version || expo.sdkVersion;
261
368
  }
262
- // Parse serverUrl and projectId from the updates URL.
263
- // Expected format: https://<host>/api/projects/<uuid>/updates
264
- // or: https://<host>/api/updates
265
- let serverUrl;
266
- let projectId = null;
267
- try {
268
- const u = new URL(updatesUrl);
269
- const projectMatch = u.pathname.match(/\/api\/projects\/([^/]+)\/updates/);
270
- if (projectMatch) {
271
- projectId = projectMatch[1];
272
- serverUrl = `${u.protocol}//${u.host}`;
273
- }
274
- else {
275
- serverUrl = `${u.protocol}//${u.host}`;
276
- }
369
+ }
370
+ if (!runtimeVersion || typeof runtimeVersion !== "string") {
371
+ printErrorBox(
372
+ "Could not resolve runtimeVersion",
373
+ `expo.runtimeVersion in app.json resolved to: ${JSON.stringify(runtimeVersion)}`,
374
+ [
375
+ `If using ${c.dim}"policy": "appVersion"${c.reset}, make sure ${c.bold}"expo.version"${c.reset} (e.g. "1.0.0") is defined in app.json.`,
376
+ `If using ${c.dim}"policy": "sdkVersion"${c.reset}, make sure ${c.bold}"expo.sdkVersion"${c.reset} (e.g. "52.0.0") is defined.`,
377
+ `Or simply specify a static string: ${c.cyan}"runtimeVersion": "1.0.0"${c.reset}`
378
+ ]
379
+ );
380
+ process.exit(1);
381
+ }
382
+ const updatesUrl = expo?.updates?.url ?? "";
383
+ if (!updatesUrl) {
384
+ printErrorBox(
385
+ "Missing expo.updates.url in app.json",
386
+ "Your app is not configured to receive updates from an EdgeOTA server.",
387
+ [
388
+ `Run ${c.cyan}edge-ota init${c.reset} in this directory to automatically configure your project.`,
389
+ `Or manually add under ${c.dim}"expo.updates"${c.reset}: ${c.cyan}"url": "https://api.ota.renbo.site/api/projects/<project-id>/updates"${c.reset}`
390
+ ]
391
+ );
392
+ process.exit(1);
393
+ }
394
+ let serverUrl;
395
+ let projectId = null;
396
+ try {
397
+ const u = new URL(updatesUrl);
398
+ const projectMatch = u.pathname.match(/\/api\/projects\/([^/]+)\/updates/);
399
+ if (projectMatch) {
400
+ projectId = projectMatch[1];
401
+ serverUrl = `${u.protocol}//${u.host}`;
402
+ } else {
403
+ serverUrl = `${u.protocol}//${u.host}`;
277
404
  }
278
- catch {
279
- console.error(` ${c.red}✗${c.reset} Could not parse ${c.dim}expo.updates.url${c.reset} in app.json`);
280
- process.exit(1);
281
- }
282
- // ── Project-level server override ──
283
- // expo.extra.edgeOtaServer lets users point a specific project at a
284
- // different (self-hosted) EdgeOTA instance without re-logging in.
285
- const projectServer = expo?.extra?.edgeOtaServer;
286
- if (projectServer) {
287
- serverUrl = projectServer.replace(/\/$/, "");
288
- }
289
- return { serverUrl, projectId, runtimeVersion, publicKey: expo?.extra?.edgeOtaPublicKey };
405
+ } catch {
406
+ printErrorBox(
407
+ "Malformed expo.updates.url",
408
+ `The URL "${updatesUrl}" in app.json is not a valid HTTP/HTTPS URL.`,
409
+ `Run ${c.cyan}edge-ota init${c.reset} to generate a valid updates URL.`
410
+ );
411
+ process.exit(1);
412
+ }
413
+ const projectServer = expo?.extra?.edgeOtaServer;
414
+ if (projectServer) {
415
+ serverUrl = projectServer.replace(/\/$/, "");
416
+ }
417
+ return { serverUrl, projectId, runtimeVersion, publicKey: expo?.extra?.edgeOtaPublicKey };
290
418
  }
291
419
  function updateAppJson(cwd, serverUrl, projectId, publicKey) {
292
- const appJsonPath = path.resolve(cwd, "app.json");
293
- if (!fs.existsSync(appJsonPath))
294
- return;
295
- try {
296
- const data = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
297
- if (!data.expo)
298
- data.expo = {};
299
- if (!data.expo.updates)
300
- data.expo.updates = {};
301
- if (!data.expo.extra)
302
- data.expo.extra = {};
303
- const cleanUrl = serverUrl.replace(/\/$/, "");
304
- data.expo.updates.url = projectId
305
- ? `${cleanUrl}/api/projects/${projectId}/updates`
306
- : `${cleanUrl}/api/updates`;
307
- data.expo.updates.checkAutomatically = data.expo.updates.checkAutomatically ?? "ON_LOAD";
308
- data.expo.updates.fallbackToCacheTimeout = data.expo.updates.fallbackToCacheTimeout ?? 30000;
309
- data.expo.updates.requestHeaders = data.expo.updates.requestHeaders ?? { "expo-channel-name": "production" };
310
- // Store the server URL in expo.extra.edgeOtaServer so users can easily
311
- // override which EdgeOTA instance this project points to without re-running
312
- // `edge-ota login`. They can edit this value directly in app.json.
313
- data.expo.extra.edgeOtaServer = cleanUrl;
314
- if (publicKey) {
315
- data.expo.extra.edgeOtaPublicKey = publicKey;
316
- }
317
- fs.writeFileSync(appJsonPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
318
- console.log(` ${c.dim}app.json${c.reset} updated ${c.dim}expo.updates.url${c.reset} + ${c.dim}expo.extra.edgeOtaServer${c.reset}`);
319
- }
320
- catch (e) {
321
- console.error(` ${c.yellow}⚠${c.reset} Could not update app.json: ${e.message}`);
420
+ const appJsonPath = path.resolve(cwd, "app.json");
421
+ if (!fs.existsSync(appJsonPath)) return;
422
+ try {
423
+ const data = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
424
+ if (!data.expo) data.expo = {};
425
+ if (!data.expo.updates) data.expo.updates = {};
426
+ if (!data.expo.extra) data.expo.extra = {};
427
+ const cleanUrl = serverUrl.replace(/\/$/, "");
428
+ data.expo.updates.url = projectId ? `${cleanUrl}/api/projects/${projectId}/updates` : `${cleanUrl}/api/updates`;
429
+ data.expo.updates.checkAutomatically = data.expo.updates.checkAutomatically ?? "ON_LOAD";
430
+ data.expo.updates.fallbackToCacheTimeout = data.expo.updates.fallbackToCacheTimeout ?? 3e4;
431
+ data.expo.updates.requestHeaders = data.expo.updates.requestHeaders ?? { "expo-channel-name": "production" };
432
+ data.expo.extra.edgeOtaServer = cleanUrl;
433
+ if (publicKey) {
434
+ data.expo.extra.edgeOtaPublicKey = publicKey;
322
435
  }
436
+ fs.writeFileSync(appJsonPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
437
+ console.log(` ${c.dim}app.json${c.reset} updated ${c.dim}expo.updates.url${c.reset} + ${c.dim}expo.extra.edgeOtaServer${c.reset}`);
438
+ } catch (e) {
439
+ printWarningBox("Could not write to app.json", e.message, "Ensure app.json is writable.");
440
+ }
323
441
  }
324
- // ─── Readline helper ─────────────────────────────────────────────────────────
325
442
  function ask(query) {
326
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
327
- return new Promise(resolve => rl.question(query, a => { rl.close(); resolve(a.trim()); }));
443
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
444
+ return new Promise((resolve) => rl.question(query, (a) => {
445
+ rl.close();
446
+ resolve(a.trim());
447
+ }));
328
448
  }
329
449
  function askSecret(query) {
330
- return new Promise(resolve => {
331
- if (process.stdin.isTTY) {
332
- process.stdout.write(query);
333
- let input = "";
334
- process.stdin.setRawMode(true);
335
- process.stdin.resume();
336
- process.stdin.setEncoding("utf8");
337
- process.stdin.on("data", function onData(ch) {
338
- if (ch === "\r" || ch === "\n") {
339
- process.stdin.setRawMode(false);
340
- process.stdin.removeListener("data", onData);
341
- process.stdout.write("\n");
342
- resolve(input);
343
- }
344
- else if (ch === "\u0003") {
345
- process.exit(0);
346
- }
347
- else if (ch === "\u007f") {
348
- if (input.length > 0) {
349
- input = input.slice(0, -1);
350
- process.stdout.write("\b \b");
351
- }
352
- }
353
- else {
354
- input += ch;
355
- process.stdout.write("•");
356
- }
357
- });
358
- }
359
- else {
360
- ask(query).then(resolve);
450
+ return new Promise((resolve) => {
451
+ if (process.stdin.isTTY) {
452
+ process.stdout.write(query);
453
+ let input = "";
454
+ process.stdin.setRawMode(true);
455
+ process.stdin.resume();
456
+ process.stdin.setEncoding("utf8");
457
+ process.stdin.on("data", function onData(ch) {
458
+ if (ch === "\r" || ch === "\n") {
459
+ process.stdin.setRawMode(false);
460
+ process.stdin.removeListener("data", onData);
461
+ process.stdout.write("\n");
462
+ resolve(input);
463
+ } else if (ch === "") {
464
+ process.exit(0);
465
+ } else if (ch === "\x7F") {
466
+ if (input.length > 0) {
467
+ input = input.slice(0, -1);
468
+ process.stdout.write("\b \b");
469
+ }
470
+ } else {
471
+ input += ch;
472
+ process.stdout.write("\u2022");
361
473
  }
362
- });
474
+ });
475
+ } else {
476
+ ask(query).then(resolve);
477
+ }
478
+ });
363
479
  }
364
480
  function selectOption(question, options) {
365
- return new Promise((resolve) => {
366
- if (!process.stdin.isTTY) {
367
- console.log(` ${c.bold}${question}${c.reset}`);
368
- options.forEach((opt, idx) => {
369
- console.log(` [${idx + 1}] ${opt.label}`);
370
- });
371
- ask(` selection (1-${options.length}): `).then((choice) => {
372
- const idx = parseInt(choice) - 1;
373
- if (!isNaN(idx) && idx >= 0 && idx < options.length) {
374
- resolve(options[idx].value);
375
- }
376
- else {
377
- resolve(options[0].value);
378
- }
379
- });
380
- return;
481
+ return new Promise((resolve) => {
482
+ if (!process.stdin.isTTY) {
483
+ console.log(` ${c.bold}${question}${c.reset}`);
484
+ options.forEach((opt, idx) => {
485
+ console.log(` [${idx + 1}] ${opt.label}`);
486
+ });
487
+ ask(` selection (1-${options.length}): `).then((choice) => {
488
+ const idx = parseInt(choice) - 1;
489
+ if (!isNaN(idx) && idx >= 0 && idx < options.length) {
490
+ resolve(options[idx].value);
491
+ } else {
492
+ resolve(options[0].value);
381
493
  }
382
- let cursor = 0;
383
- const hideCursor = "\u001b[?25l";
384
- const showCursor = "\u001b[?25h";
385
- let hasRendered = false;
386
- console.log(` ${c.bold}${question}${c.reset}`);
387
- function render() {
388
- if (hasRendered) {
389
- for (let i = 0; i < options.length; i++) {
390
- process.stdout.write("\r\u001b[K\u001b[A");
391
- }
392
- process.stdout.write("\r\u001b[K");
393
- }
394
- hasRendered = true;
395
- options.forEach((opt, idx) => {
396
- const isSelected = idx === cursor;
397
- const marker = isSelected ? `${c.cyan}❯${c.reset}` : " ";
398
- const text = isSelected ? `${c.bold}${c.cyan}${opt.label}${c.reset}` : `${c.dim}${opt.label}${c.reset}`;
399
- process.stdout.write(` ${marker} ${text}\n`);
400
- });
494
+ });
495
+ return;
496
+ }
497
+ let cursor = 0;
498
+ const hideCursor = "\x1B[?25l";
499
+ const showCursor = "\x1B[?25h";
500
+ let hasRendered = false;
501
+ console.log(` ${c.bold}${question}${c.reset}`);
502
+ function render() {
503
+ if (hasRendered) {
504
+ for (let i = 0; i < options.length; i++) {
505
+ process.stdout.write("\r\x1B[K\x1B[A");
401
506
  }
402
- readline.emitKeypressEvents(process.stdin);
403
- process.stdin.setRawMode(true);
404
- process.stdin.resume();
405
- process.stdout.write(hideCursor);
507
+ process.stdout.write("\r\x1B[K");
508
+ }
509
+ hasRendered = true;
510
+ options.forEach((opt, idx) => {
511
+ const isSelected = idx === cursor;
512
+ const marker = isSelected ? `${c.cyan}\u276F${c.reset}` : " ";
513
+ const text = isSelected ? `${c.bold}${c.cyan}${opt.label}${c.reset}` : `${c.dim}${opt.label}${c.reset}`;
514
+ process.stdout.write(` ${marker} ${text}
515
+ `);
516
+ });
517
+ }
518
+ readline.emitKeypressEvents(process.stdin);
519
+ process.stdin.setRawMode(true);
520
+ process.stdin.resume();
521
+ process.stdout.write(hideCursor);
522
+ render();
523
+ function onKeypress(str, key) {
524
+ if (key.ctrl && key.name === "c") {
525
+ process.stdout.write(showCursor);
526
+ process.stdin.setRawMode(false);
527
+ process.stdin.pause();
528
+ process.exit(0);
529
+ }
530
+ if (key.name === "up" || key.name === "k") {
531
+ cursor = (cursor - 1 + options.length) % options.length;
406
532
  render();
407
- function onKeypress(str, key) {
408
- if (key.ctrl && key.name === "c") {
409
- process.stdout.write(showCursor);
410
- process.stdin.setRawMode(false);
411
- process.stdin.pause();
412
- process.exit(0);
413
- }
414
- if (key.name === "up" || key.name === "k") {
415
- cursor = (cursor - 1 + options.length) % options.length;
416
- render();
417
- }
418
- else if (key.name === "down" || key.name === "j") {
419
- cursor = (cursor + 1) % options.length;
420
- render();
421
- }
422
- else if (key.name === "return" || key.name === "enter") {
423
- process.stdout.write(showCursor);
424
- process.stdin.setRawMode(false);
425
- process.stdin.removeListener("keypress", onKeypress);
426
- process.stdin.pause();
427
- resolve(options[cursor].value);
428
- }
429
- }
430
- process.stdin.on("keypress", onKeypress);
431
- });
533
+ } else if (key.name === "down" || key.name === "j") {
534
+ cursor = (cursor + 1) % options.length;
535
+ render();
536
+ } else if (key.name === "return" || key.name === "enter") {
537
+ process.stdout.write(showCursor);
538
+ process.stdin.setRawMode(false);
539
+ process.stdin.removeListener("keypress", onKeypress);
540
+ process.stdin.pause();
541
+ resolve(options[cursor].value);
542
+ }
543
+ }
544
+ process.stdin.on("keypress", onKeypress);
545
+ });
432
546
  }
433
- const CONTENT_TYPES = {
434
- ".js": "application/javascript",
435
- ".hbc": "application/javascript",
436
- ".json": "application/json",
437
- ".png": "image/png",
438
- ".jpg": "image/jpeg",
439
- ".jpeg": "image/jpeg",
440
- ".gif": "image/gif",
441
- ".svg": "image/svg+xml",
442
- ".ttf": "font/ttf",
443
- ".otf": "font/otf",
444
- ".woff": "font/woff",
445
- ".woff2": "font/woff2",
446
- ".mp4": "video/mp4",
447
- ".webm": "video/webm",
547
+ var CONTENT_TYPES = {
548
+ ".js": "application/javascript",
549
+ ".hbc": "application/javascript",
550
+ ".json": "application/json",
551
+ ".png": "image/png",
552
+ ".jpg": "image/jpeg",
553
+ ".jpeg": "image/jpeg",
554
+ ".gif": "image/gif",
555
+ ".svg": "image/svg+xml",
556
+ ".ttf": "font/ttf",
557
+ ".otf": "font/otf",
558
+ ".woff": "font/woff",
559
+ ".woff2": "font/woff2",
560
+ ".mp4": "video/mp4",
561
+ ".webm": "video/webm"
448
562
  };
449
563
  async function hashFile(filePath) {
450
- const buffer = fs.readFileSync(filePath);
451
- return sha256Hex(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
564
+ const buffer = fs.readFileSync(filePath);
565
+ return sha256Hex(buffer.buffer.slice(
566
+ buffer.byteOffset,
567
+ buffer.byteOffset + buffer.byteLength
568
+ ));
452
569
  }
453
570
  async function collectAssets(distDir) {
454
- const entries = [];
455
- function walk(dir) {
456
- for (const name of fs.readdirSync(dir)) {
457
- const full = path.join(dir, name);
458
- if (fs.statSync(full).isDirectory()) {
459
- walk(full);
460
- }
461
- else {
462
- const ext = path.extname(name).toLowerCase();
463
- entries.push({
464
- localPath: full,
465
- key: path.relative(distDir, full).replace(/\\/g, "/"),
466
- contentType: CONTENT_TYPES[ext] || "application/octet-stream",
467
- hash: ""
468
- });
469
- }
470
- }
571
+ const entries = [];
572
+ function walk(dir) {
573
+ for (const name of fs.readdirSync(dir)) {
574
+ const full = path.join(dir, name);
575
+ if (fs.statSync(full).isDirectory()) {
576
+ walk(full);
577
+ } else {
578
+ const ext = path.extname(name).toLowerCase();
579
+ entries.push({
580
+ localPath: full,
581
+ key: path.relative(distDir, full).replace(/\\/g, "/"),
582
+ contentType: CONTENT_TYPES[ext] || "application/octet-stream",
583
+ hash: ""
584
+ });
585
+ }
471
586
  }
472
- walk(distDir);
473
- await Promise.all(entries.map(async (e) => { e.hash = await hashFile(e.localPath); }));
474
- return entries;
587
+ }
588
+ walk(distDir);
589
+ await Promise.all(entries.map(async (e) => {
590
+ e.hash = await hashFile(e.localPath);
591
+ }));
592
+ return entries;
475
593
  }
476
594
  function findBundle(distDir, platform) {
477
- const expoStaticJs = path.join(distDir, "_expo", "static", "js", platform);
478
- if (fs.existsSync(expoStaticJs)) {
479
- const files = fs.readdirSync(expoStaticJs).filter(f => f.endsWith(".js") || f.endsWith(".hbc"));
480
- if (files.length)
481
- return path.join(expoStaticJs, files[0]);
482
- }
483
- const flatJs = path.join(distDir, `index.${platform}.js`);
484
- if (fs.existsSync(flatJs))
485
- return flatJs;
486
- const flatHbc = path.join(distDir, `index.${platform}.hbc`);
487
- if (fs.existsSync(flatHbc))
488
- return flatHbc;
489
- const rootJs = fs.readdirSync(distDir).find(f => f.endsWith(".js") || f.endsWith(".hbc"));
490
- if (rootJs)
491
- return path.join(distDir, rootJs);
492
- return null;
595
+ const expoStaticJs = path.join(distDir, "_expo", "static", "js", platform);
596
+ if (fs.existsSync(expoStaticJs)) {
597
+ const files = fs.readdirSync(expoStaticJs).filter((f) => f.endsWith(".js") || f.endsWith(".hbc"));
598
+ if (files.length) return path.join(expoStaticJs, files[0]);
599
+ }
600
+ const flatJs = path.join(distDir, `index.${platform}.js`);
601
+ if (fs.existsSync(flatJs)) return flatJs;
602
+ const flatHbc = path.join(distDir, `index.${platform}.hbc`);
603
+ if (fs.existsSync(flatHbc)) return flatHbc;
604
+ const rootJs = fs.readdirSync(distDir).find((f) => f.endsWith(".js") || f.endsWith(".hbc"));
605
+ if (rootJs) return path.join(distDir, rootJs);
606
+ return null;
493
607
  }
494
- // ─── Auth token resolution ────────────────────────────────────────────────────
495
608
  function resolveToken() {
496
- // 1. Global config
497
- const cfg = loadGlobalConfig();
498
- if (cfg?.token)
499
- return cfg.token;
500
- // 2. Env var fallback
501
- return process.env.EDGE_OTA_TOKEN || null;
609
+ const cfg = loadGlobalConfig();
610
+ if (cfg?.token) return cfg.token;
611
+ return process.env.EDGE_OTA_TOKEN || null;
502
612
  }
503
- // ─── Program ─────────────────────────────────────────────────────────────────
504
- const program = new Command();
505
- program
506
- .name("edge-ota")
507
- .description("Zero-SDK OTA update platform for Expo — by Renbo Studios")
508
- .version(packageVersion);
509
- // Show help + auth status when called with no arguments
613
+ var program = new Command();
614
+ program.name("edge-ota").description("Zero-SDK, self-hostable OTA update platform for Expo \u2014 by Renbo Studios").version(packageVersion);
615
+ program.addHelpText("after", `
616
+ ${c.bold}Quick Start:${c.reset}
617
+ 1. ${c.cyan}edge-ota login${c.reset} Authenticate with EdgeOTA
618
+ 2. ${c.cyan}edge-ota init${c.reset} Configure your Expo app and generate signing keys
619
+ 3. ${c.cyan}npx expo prebuild --clean${c.reset} Bake the server URL into native files (Required!)
620
+ 4. ${c.cyan}edge-ota push${c.reset} Export and publish your first OTA update
621
+
622
+ ${c.bold}Documentation & Support:${c.reset}
623
+ Website: https://ota.renbo.site
624
+ Studio: https://renbostudios.com
625
+ GitHub: https://github.com/renbostudios/edge-ota-cli
626
+ `);
510
627
  program.action(() => {
511
- printBanner();
512
- const cfg = loadGlobalConfig();
513
- if (cfg?.email) {
514
- console.log(` ${c.green}●${c.reset} Logged in as ${c.bold}${cfg.email}${c.reset} ${c.dim}(${cfg.serverUrl})${c.reset}`);
515
- }
516
- else {
517
- console.log(` ${c.dim}○ Not logged in — run ${c.reset}${c.cyan}edge-ota login${c.reset}`);
518
- }
519
- console.log();
520
- console.log(` ${c.bold}Commands${c.reset}`);
521
- const cmds = [
522
- ["login", "Authenticate and save credentials globally"],
523
- ["logout", "Remove stored credentials"],
524
- ["init", "Register project, generate signing keys, configure app.json"],
525
- ["push", "Export bundle and publish an OTA update"],
526
- ["status", "List recent deployments for this project"],
527
- ["keygen", "Generate a new ECDSA key pair (prints to stdout)"],
528
- ];
529
- for (const [cmd, desc] of cmds) {
530
- console.log(` ${c.cyan}${cmd.padEnd(10)}${c.reset} ${c.dim}${desc}${c.reset}`);
531
- }
532
- console.log();
533
- console.log(` Run ${c.cyan}edge-ota <command> --help${c.reset} for command-specific options.`);
534
- console.log();
628
+ printBanner();
629
+ const cfg = loadGlobalConfig();
630
+ if (cfg?.email) {
631
+ console.log(` ${c.green}\u25CF${c.reset} Logged in as ${c.bold}${cfg.email}${c.reset} ${c.dim}(${cfg.serverUrl})${c.reset}`);
632
+ } else {
633
+ console.log(` ${c.dim}\u25CB Not logged in \u2014 run ${c.reset}${c.cyan}edge-ota login${c.reset}`);
634
+ }
635
+ console.log();
636
+ console.log(` ${c.bold}Commands${c.reset}`);
637
+ const cmds = [
638
+ ["login", "Authenticate and save credentials globally"],
639
+ ["logout", "Remove stored credentials"],
640
+ ["init", "Register project, generate signing keys, configure app.json"],
641
+ ["push", "Export bundle and publish an OTA update"],
642
+ ["status", "List recent deployments for this project"],
643
+ ["keygen", "Generate a new ECDSA key pair (prints to stdout)"]
644
+ ];
645
+ for (const [cmd, desc] of cmds) {
646
+ console.log(` ${c.cyan}${cmd.padEnd(10)}${c.reset} ${c.dim}${desc}${c.reset}`);
647
+ }
648
+ console.log();
649
+ console.log(` Run ${c.cyan}edge-ota <command> --help${c.reset} for detailed command options and examples.`);
650
+ console.log();
535
651
  });
536
- // ──────────────────────────────────────────────────────────────────────────────
537
- // edge-ota login
538
- // ──────────────────────────────────────────────────────────────────────────────
539
- program
540
- .command("login")
541
- .description("Authenticate with your EdgeOTA account")
542
- .option("-s, --server <url>", `EdgeOTA server URL (default: ${DEFAULT_SERVER})`)
543
- .action(async (options) => {
544
- printBanner();
545
- const usingCustomServer = options.server && options.server !== DEFAULT_SERVER;
546
- console.log(` ${c.bold}Sign in to EdgeOTA${c.reset}`);
547
- if (usingCustomServer) {
548
- console.log(` ${c.dim}server: ${options.server}${c.reset}`);
549
- }
550
- console.log();
551
- const serverUrl = (options.server || DEFAULT_SERVER).replace(/\/$/, "");
552
- const email = await ask(` ${c.dim}email:${c.reset} `);
553
- const password = await askSecret(` ${c.dim}password:${c.reset} `);
554
- spin.start("authenticating");
555
- try {
556
- const res = await fetch(`${serverUrl}/api/auth/login`, {
557
- method: "POST",
558
- headers: { "Content-Type": "application/json" },
559
- body: JSON.stringify({ email, password }),
560
- });
561
- if (!res.ok) {
562
- const text = await res.text();
563
- spin.fail(`Login failed: ${text || res.statusText}`);
564
- process.exit(1);
565
- }
566
- const data = await res.json();
567
- saveGlobalConfig({ token: data.token, email: data.email, serverUrl });
568
- spin.stop();
569
- const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
570
- console.log(sep);
571
- console.log(` ${c.green}✓${c.reset} Logged in as ${c.bold}${data.email}${c.reset}`);
572
- console.log(` ${c.dim}credentials saved to ${GLOBAL_CONFIG_FILE}${c.reset}`);
573
- console.log(sep + "\n");
574
- }
575
- catch (e) {
576
- spin.fail(`Connection failed: ${e.message}`);
577
- process.exit(1);
652
+ program.command("login").description("Authenticate with your EdgeOTA account and save credentials globally").option("-s, --server <url>", "EdgeOTA server URL", DEFAULT_SERVER).addHelpText("after", `
653
+ ${c.bold}Examples:${c.reset}
654
+ $ ${c.cyan}edge-ota login${c.reset} Sign in to standard cloud service (${DEFAULT_SERVER})
655
+ $ ${c.cyan}edge-ota login -s http://localhost:3020${c.reset} Sign in to a self-hosted local server
656
+ $ ${c.cyan}edge-ota login -s https://ota.mycompany.com${c.reset} Sign in to a self-hosted custom domain
657
+
658
+ ${c.bold}CI/CD Environments:${c.reset}
659
+ Instead of interactive login, set the ${c.bold}EDGE_OTA_TOKEN${c.reset} environment variable:
660
+ $ ${c.cyan}export EDGE_OTA_TOKEN="eota_prod_..."${c.reset}
661
+ `).action(async (options) => {
662
+ printBanner();
663
+ const usingCustomServer = options.server && options.server !== DEFAULT_SERVER;
664
+ console.log(` ${c.bold}Sign in to EdgeOTA${c.reset}`);
665
+ if (usingCustomServer) {
666
+ console.log(` ${c.dim}server: ${options.server}${c.reset}`);
667
+ }
668
+ console.log();
669
+ const serverUrl = (options.server || DEFAULT_SERVER).replace(/\/$/, "");
670
+ const email = await ask(` ${c.dim}email:${c.reset} `);
671
+ const password = await askSecret(` ${c.dim}password:${c.reset} `);
672
+ spin.start("authenticating");
673
+ try {
674
+ const res = await fetch(`${serverUrl}/api/auth/login`, {
675
+ method: "POST",
676
+ headers: { "Content-Type": "application/json" },
677
+ body: JSON.stringify({ email, password })
678
+ });
679
+ if (!res.ok) {
680
+ const text = await res.text();
681
+ spin.fail("Authentication failed");
682
+ if (res.status === 401) {
683
+ printErrorBox(
684
+ "Invalid email or password",
685
+ text || "The credentials provided were rejected by the server.",
686
+ [
687
+ `Check your email and password for typos.`,
688
+ `If you don't have an account yet, register at ${c.bold}https://ota.renbo.site${c.reset}`,
689
+ `If using self-hosted server, verify ${c.bold}${serverUrl}${c.reset} is correct.`
690
+ ]
691
+ );
692
+ } else if (res.status === 403) {
693
+ printErrorBox(
694
+ "Account email not verified",
695
+ text || "Your account email has not been verified.",
696
+ `Check your inbox for the verification OTP or log in to the dashboard to verify your email.`
697
+ );
698
+ } else {
699
+ printErrorBox(
700
+ `Server returned HTTP ${res.status}`,
701
+ text || res.statusText,
702
+ `Check server logs or try again shortly.`
703
+ );
704
+ }
705
+ process.exit(1);
578
706
  }
707
+ const data = await res.json();
708
+ saveGlobalConfig({ token: data.token, email: data.email, serverUrl });
709
+ spin.stop();
710
+ const sep = `${c.dim}${"\u2500".repeat(56)}${c.reset}`;
711
+ console.log(sep);
712
+ console.log(` ${c.green}\u2713${c.reset} Logged in as ${c.bold}${data.email}${c.reset}`);
713
+ console.log(` ${c.dim}credentials saved to ${GLOBAL_CONFIG_FILE}${c.reset}`);
714
+ console.log(sep + "\n");
715
+ process.exit(0);
716
+ } catch (e) {
717
+ spin.fail("Connection failed");
718
+ const diag = diagnoseNetworkError(e, serverUrl);
719
+ printErrorBox(diag.title, diag.reason, diag.solution);
720
+ process.exit(1);
721
+ }
579
722
  });
580
- // ──────────────────────────────────────────────────────────────────────────────
581
- // edge-ota logout
582
- // ──────────────────────────────────────────────────────────────────────────────
583
- program
584
- .command("logout")
585
- .description("Remove stored credentials")
586
- .action(() => {
587
- printBanner();
588
- const cfg = loadGlobalConfig();
589
- clearGlobalConfig();
590
- if (cfg?.email) {
591
- console.log(` ${c.green}✓${c.reset} Logged out from ${c.bold}${cfg.email}${c.reset}\n`);
723
+ program.command("logout").description("Remove stored credentials and log out").addHelpText("after", `
724
+ ${c.bold}Details:${c.reset}
725
+ Deletes the global configuration file at ${c.dim}${GLOBAL_CONFIG_FILE}${c.reset}.
726
+ Project private signing keys in ${c.dim}${GLOBAL_KEYS_DIR}${c.reset} are preserved.
727
+ `).action(() => {
728
+ printBanner();
729
+ const cfg = loadGlobalConfig();
730
+ clearGlobalConfig();
731
+ if (cfg?.email) {
732
+ console.log(` ${c.green}\u2713${c.reset} Logged out from ${c.bold}${cfg.email}${c.reset}
733
+ `);
734
+ } else {
735
+ console.log(` ${c.dim}Already logged out.${c.reset}
736
+ `);
737
+ }
738
+ process.exit(0);
739
+ });
740
+ program.command("init").description("Register project on EdgeOTA, generate signing keys, and configure app.json").option("-s, --server <url>", "EdgeOTA server URL (overrides logged-in server)").addHelpText("after", `
741
+ ${c.bold}What this command does:${c.reset}
742
+ 1. Authenticates with your EdgeOTA server
743
+ 2. Creates or links a project ID
744
+ 3. Generates a cryptographic ECDSA P-256 code-signing keypair
745
+ 4. Stores private key securely in ${c.dim}~/.config/edge-ota/keys/<projectId>.key${c.reset}
746
+ 5. Updates ${c.bold}app.json${c.reset} with ${c.dim}expo.updates.url${c.reset} and public key
747
+
748
+ ${c.bold}Critical Next Step:${c.reset}
749
+ After running ${c.cyan}edge-ota init${c.reset}, you ${c.bold}MUST rebuild your native app${c.reset}:
750
+ $ ${c.cyan}npx expo prebuild --clean${c.reset}
751
+ $ ${c.cyan}npx expo run:android${c.reset} (or ${c.cyan}npx expo run:ios${c.reset} / ${c.cyan}eas build${c.reset})
752
+ `).action(async (options) => {
753
+ printBanner();
754
+ const token = resolveToken();
755
+ if (!token) {
756
+ printErrorBox(
757
+ "Not logged in",
758
+ "You must be authenticated to initialize a project.",
759
+ [
760
+ `Run ${c.cyan}edge-ota login${c.reset} to authenticate interactively.`,
761
+ `Or set ${c.cyan}export EDGE_OTA_TOKEN="eota_prod_..."${c.reset} in your environment.`
762
+ ]
763
+ );
764
+ process.exit(1);
765
+ }
766
+ const globalCfg = loadGlobalConfig();
767
+ const serverUrl = (options.server || globalCfg?.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
768
+ const cwd = process.cwd();
769
+ spin.start("fetching existing projects from server");
770
+ let projects = [];
771
+ try {
772
+ const res = await fetch(`${serverUrl}/api/projects`, {
773
+ headers: { Authorization: `Bearer ${token}` }
774
+ });
775
+ if (res.ok) {
776
+ projects = await res.json();
592
777
  }
593
- else {
594
- console.log(` ${c.dim}Already logged out.${c.reset}\n`);
778
+ spin.stop();
779
+ } catch (e) {
780
+ spin.fail("Failed to fetch projects list");
781
+ const diag = diagnoseNetworkError(e, serverUrl);
782
+ printWarningBox(diag.title, diag.reason, "Proceeding with new project registration...");
783
+ }
784
+ let projectId = null;
785
+ let projectName = "";
786
+ if (projects.length > 0) {
787
+ const selectOptions = projects.map((p) => ({
788
+ label: `${p.name} ${c.dim}(id: ${p.id.slice(0, 8)}...)${c.reset}`,
789
+ value: p
790
+ }));
791
+ selectOptions.push({
792
+ label: `${c.green}+ Create a new project...${c.reset}`,
793
+ value: null
794
+ });
795
+ const selected = await selectOption(
796
+ "Select a project to associate with this app:",
797
+ selectOptions
798
+ );
799
+ if (selected) {
800
+ projectId = selected.id;
801
+ projectName = selected.name;
595
802
  }
596
- });
597
- // ──────────────────────────────────────────────────────────────────────────────
598
- // edge-ota init
599
- // ──────────────────────────────────────────────────────────────────────────────
600
- program
601
- .command("init")
602
- .description("Register project on EdgeOTA, generate signing keys, configure app.json")
603
- .option("-s, --server <url>", "EdgeOTA server URL (overrides logged-in server)")
604
- .action(async (options) => {
605
- printBanner();
606
- const token = resolveToken();
607
- if (!token) {
608
- console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.\n`);
803
+ }
804
+ const keys = await generateECDSAKeyPair();
805
+ if (projectId) {
806
+ spin.start(`associating project "${projectName}"`);
807
+ try {
808
+ const res = await fetch(`${serverUrl}/api/projects/${projectId}`, {
809
+ method: "PUT",
810
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
811
+ body: JSON.stringify({ name: projectName, publicKey: keys.publicKey })
812
+ });
813
+ if (!res.ok) {
814
+ const text = await res.text();
815
+ spin.fail("Failed to update project public key");
816
+ printErrorBox(
817
+ `Server returned HTTP ${res.status}`,
818
+ text,
819
+ `Ensure your user account has administrator or member permissions for project "${projectName}".`
820
+ );
609
821
  process.exit(1);
822
+ }
823
+ spin.stop(`associated project "${projectName}"`);
824
+ } catch (e) {
825
+ spin.fail("Connection error during project update");
826
+ const diag = diagnoseNetworkError(e, serverUrl);
827
+ printErrorBox(diag.title, diag.reason, diag.solution);
828
+ process.exit(1);
610
829
  }
611
- const globalCfg = loadGlobalConfig();
612
- const serverUrl = (options.server || globalCfg?.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
613
- const cwd = process.cwd();
614
- // Fetch existing projects
615
- spin.start("fetching existing projects from server");
616
- let projects = [];
830
+ } else {
831
+ let suggestedName = path.basename(cwd);
617
832
  try {
618
- const res = await fetch(`${serverUrl}/api/projects`, {
619
- headers: { Authorization: `Bearer ${token}` }
620
- });
621
- if (res.ok) {
622
- projects = await res.json();
623
- }
624
- spin.stop();
625
- }
626
- catch (e) {
627
- spin.fail(`Failed to fetch projects: ${e.message}`);
628
- // Continue with creation if list fails
629
- }
630
- let projectId = null;
631
- let projectName = "";
632
- if (projects.length > 0) {
633
- const options = projects.map(p => ({
634
- label: `${p.name} ${c.dim}(id: ${p.id.slice(0, 8)}...)${c.reset}`,
635
- value: p
636
- }));
637
- options.push({
638
- label: `${c.green}Create a new project...${c.reset}`,
639
- value: null
640
- });
641
- const selected = await selectOption("Select a project to associate with this app:", options);
642
- if (selected) {
643
- projectId = selected.id;
644
- projectName = selected.name;
645
- }
833
+ const data = JSON.parse(fs.readFileSync(path.resolve(cwd, "app.json"), "utf-8"));
834
+ suggestedName = data?.expo?.name || suggestedName;
835
+ } catch {
646
836
  }
647
- const keys = await generateECDSAKeyPair();
648
- if (projectId) {
649
- // Re-initialize existing project: update public key on the server
650
- spin.start(`associating project "${projectName}"`);
651
- try {
652
- const res = await fetch(`${serverUrl}/api/projects/${projectId}`, {
653
- method: "PUT",
654
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
655
- body: JSON.stringify({ name: projectName, publicKey: keys.publicKey }),
656
- });
657
- if (!res.ok) {
658
- const text = await res.text();
659
- spin.fail(`Failed to update project public key on server: ${text}`);
660
- process.exit(1);
661
- }
662
- spin.stop(`associated project "${projectName}"`);
663
- }
664
- catch (e) {
665
- spin.fail(`Connection error: ${e.message}`);
666
- process.exit(1);
667
- }
837
+ projectName = await ask(` ${c.dim}project name [${suggestedName}]:${c.reset} `) || suggestedName;
838
+ spin.start("registering project on server");
839
+ try {
840
+ const res = await fetch(`${serverUrl}/api/projects`, {
841
+ method: "POST",
842
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
843
+ body: JSON.stringify({ name: projectName, publicKey: keys.publicKey })
844
+ });
845
+ if (!res.ok) {
846
+ const text = await res.text();
847
+ spin.fail("Failed to register project");
848
+ printErrorBox(
849
+ `Server returned HTTP ${res.status}`,
850
+ text,
851
+ `Check that your session token is valid and project name is acceptable.`
852
+ );
853
+ process.exit(1);
854
+ }
855
+ const data = await res.json();
856
+ projectId = data.id;
857
+ spin.stop("project registered");
858
+ } catch (e) {
859
+ spin.fail("Connection error during registration");
860
+ const diag = diagnoseNetworkError(e, serverUrl);
861
+ printErrorBox(diag.title, diag.reason, diag.solution);
862
+ process.exit(1);
668
863
  }
669
- else {
670
- // Create new project
671
- let suggestedName = path.basename(cwd);
672
- try {
673
- const data = JSON.parse(fs.readFileSync(path.resolve(cwd, "app.json"), "utf-8"));
674
- suggestedName = data?.expo?.name || suggestedName;
675
- }
676
- catch { /* ignore */ }
677
- projectName = (await ask(` ${c.dim}project name [${suggestedName}]:${c.reset} `)) || suggestedName;
678
- spin.start("registering project on server");
679
- try {
680
- const res = await fetch(`${serverUrl}/api/projects`, {
681
- method: "POST",
682
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
683
- body: JSON.stringify({ name: projectName, publicKey: keys.publicKey }),
684
- });
685
- if (!res.ok) {
686
- const text = await res.text();
687
- spin.fail(`Failed to register project: ${text}`);
688
- process.exit(1);
689
- }
690
- const data = await res.json();
691
- projectId = data.id;
692
- spin.stop("project registered");
864
+ }
865
+ if (projectId) {
866
+ savePrivateKey(projectId, keys.privateKey);
867
+ updateAppJson(cwd, serverUrl, projectId, keys.publicKey);
868
+ const sep = `${c.dim}${"\u2500".repeat(56)}${c.reset}`;
869
+ console.log(`
870
+ ${sep}`);
871
+ console.log(` ${c.green}\u2713${c.reset} ${c.bold}Initialised Successfully${c.reset}`);
872
+ console.log(sep);
873
+ console.log(` project ${c.dim}${projectId}${c.reset}`);
874
+ console.log(` server ${c.dim}${serverUrl}${c.reset}`);
875
+ console.log(` key file ${c.dim}${path.join(GLOBAL_KEYS_DIR, `${projectId}.key`)}${c.reset}`);
876
+ console.log(sep);
877
+ console.log(`
878
+ ${c.bold}${c.yellow} \u26A0 REBUILD REQUIRED${c.reset}`);
879
+ console.log(sep);
880
+ console.log(` ${c.bold}Your native app MUST be rebuilt for OTA updates${c.reset}`);
881
+ console.log(` ${c.bold}to work.${c.reset} The server URL is baked into native`);
882
+ console.log(` binary configurations at build time \u2014 not read dynamically from app.json.`);
883
+ console.log();
884
+ console.log(` Run these commands in order:`);
885
+ console.log();
886
+ console.log(` ${c.cyan}npx expo prebuild --clean${c.reset}`);
887
+ console.log(` ${c.cyan}npx expo run:android${c.reset} (or ${c.cyan}npx expo run:ios${c.reset})`);
888
+ console.log();
889
+ console.log(` Or via EAS Build:`);
890
+ console.log(` ${c.cyan}eas build --profile production${c.reset}`);
891
+ console.log();
892
+ console.log(` ${c.red}Skipping this WILL cause "Failed to check for${c.reset}`);
893
+ console.log(` ${c.red}update" errors on app launch.${c.reset}`);
894
+ console.log(sep + "\n");
895
+ console.log(` Then run ${c.cyan}edge-ota push${c.reset} to publish your first update.
896
+ `);
897
+ process.exit(0);
898
+ }
899
+ });
900
+ program.command("push").description("Export bundle, sign with ECDSA P-256, and publish an OTA update").option("-c, --channel <channel>", "Deployment channel", "production").option("-p, --platform <platform>", "Target platform: ios | android | all", "all").option("-r, --runtime <runtime>", "Override target runtime version (comma-separated for multi-runtime matrix, e.g. 1.0.0,1.0.1)").option("--skip-export", "Skip expo export (use existing ./dist directory)").option("--dry-run", "Build and sign payload locally without uploading").addHelpText("after", `
901
+ ${c.bold}Examples:${c.reset}
902
+ $ ${c.cyan}edge-ota push${c.reset} Publish update to 'production' channel
903
+ $ ${c.cyan}edge-ota push -c staging${c.reset} Publish update to 'staging' channel
904
+ $ ${c.cyan}edge-ota push -p android${c.reset} Publish to Android only
905
+ $ ${c.cyan}edge-ota push -r 1.0.5${c.reset} Override runtime version to 1.0.5
906
+ $ ${c.cyan}edge-ota push -r 1.0.0,1.0.1,2.0.0${c.reset} Hotfix multiple native runtime versions simultaneously
907
+ $ ${c.cyan}edge-ota push --skip-export${c.reset} Deploy existing ./dist without re-running expo export
908
+ $ ${c.cyan}edge-ota push --dry-run${c.reset} Verify build, asset collection, and signature locally
909
+
910
+ ${c.bold}How It Works:${c.reset}
911
+ 1. Runs ${c.dim}npx expo export${c.reset} to produce Hermes bytecode bundles (.hbc)
912
+ 2. Discovers and uploads static media assets (.png, .jpg, .svg, .ttf, etc.)
913
+ 3. Signs bundle hash with private key in ${c.dim}~/.config/edge-ota/keys/<projectId>.key${c.reset}
914
+ 4. Publishes update to EdgeOTA server for instant client OTA sync
915
+ `).action(async (options) => {
916
+ const cwd = process.cwd();
917
+ const token = resolveToken();
918
+ printBanner();
919
+ if (!token) {
920
+ printErrorBox(
921
+ "Not logged in",
922
+ "You must be authenticated to publish an OTA update.",
923
+ [
924
+ `Run ${c.cyan}edge-ota login${c.reset} to sign in to your EdgeOTA account.`,
925
+ `Or set ${c.cyan}export EDGE_OTA_TOKEN="eota_prod_..."${c.reset} in your environment (recommended for CI/CD).`
926
+ ]
927
+ );
928
+ process.exit(1);
929
+ }
930
+ const appCfg = readAppJson(cwd);
931
+ const { serverUrl, projectId } = appCfg;
932
+ const runtimeVersion = options.runtime || appCfg.runtimeVersion;
933
+ const privateKey = projectId ? loadPrivateKey(projectId) : null;
934
+ if (!privateKey) {
935
+ const keyPath = projectId ? path.join(GLOBAL_KEYS_DIR, `${projectId}.key`) : "unknown";
936
+ printErrorBox(
937
+ "No signing key found for this project",
938
+ `Expected private key file at: ${keyPath}`,
939
+ [
940
+ `Run ${c.cyan}edge-ota init${c.reset} to re-associate this project and generate a new keypair.`,
941
+ `If collaborating with a team, copy the project's private key into ${c.bold}${keyPath}${c.reset}`,
942
+ `Or generate keys manually using ${c.cyan}edge-ota keygen${c.reset}`
943
+ ]
944
+ );
945
+ process.exit(1);
946
+ }
947
+ const uploadUrl = projectId ? `${serverUrl}/api/projects/${projectId}/updates` : `${serverUrl}/api/updates`;
948
+ const distDir = path.resolve(cwd, "dist");
949
+ if (!options.skipExport) {
950
+ spin.start("running expo export");
951
+ await new Promise((resolve, reject) => {
952
+ const proc = spawn("npx", ["expo", "export"], {
953
+ cwd,
954
+ stdio: ["inherit", "pipe", "pipe"],
955
+ shell: process.platform === "win32"
956
+ });
957
+ proc.stdout?.on("data", (chunk) => {
958
+ const text = chunk.toString();
959
+ for (const line of text.split("\n")) {
960
+ const t = line.trim();
961
+ if (t.startsWith("_expo/static/js/") || t.match(/\.(hbc|js)\s+\(\d/)) {
962
+ spin.stop();
963
+ console.log(` ${c.dim}${t}${c.reset}`);
964
+ spin.start("running expo export");
965
+ }
966
+ if (t.match(/Bundled \d+ms/)) {
967
+ spin.update("expo export \u2014 " + t.replace("Bundled", "").trim());
968
+ }
693
969
  }
694
- catch (e) {
695
- spin.fail(`Connection error: ${e.message}`);
696
- process.exit(1);
970
+ });
971
+ let stderrBuf = "";
972
+ proc.stderr?.on("data", (chunk) => {
973
+ stderrBuf += chunk.toString();
974
+ });
975
+ proc.on("close", (code) => {
976
+ if (code === 0) {
977
+ spin.stop("expo export complete");
978
+ resolve();
979
+ } else {
980
+ spin.fail("expo export failed");
981
+ printErrorBox(
982
+ "Expo export failed",
983
+ `npx expo export exited with code ${code}`,
984
+ [
985
+ `Run ${c.bold}npx expo export${c.reset} directly in your terminal to see full Metro compiler logs.`,
986
+ `Check for TypeScript compiler errors, missing dependencies, or invalid asset imports.`,
987
+ `Recent stderr output:
988
+ ${c.dim}${stderrBuf.slice(-400).trim()}${c.reset}`
989
+ ]
990
+ );
991
+ reject(new Error(`expo export exited with code ${code}`));
697
992
  }
993
+ });
994
+ }).catch(() => process.exit(1));
995
+ } else {
996
+ console.log(` ${c.dim}skip-export \u2014 using existing ./dist${c.reset}`);
997
+ }
998
+ if (!fs.existsSync(distDir)) {
999
+ printErrorBox(
1000
+ "dist directory not found",
1001
+ `Expected export output at ${distDir}`,
1002
+ [
1003
+ `Run ${c.cyan}edge-ota push${c.reset} without ${c.dim}--skip-export${c.reset} to automatically generate bundles.`,
1004
+ `Or manually build using ${c.cyan}npx expo export${c.reset}`
1005
+ ]
1006
+ );
1007
+ process.exit(1);
1008
+ }
1009
+ spin.start("collecting assets");
1010
+ const assets = await collectAssets(distDir);
1011
+ const mediaAssets = assets.filter((a) => !a.key.endsWith(".js") && !a.key.endsWith(".hbc"));
1012
+ spin.stop(`found ${assets.length} total asset(s) (${mediaAssets.length} static assets)`);
1013
+ if (mediaAssets.length > 0 && !options.dryRun) {
1014
+ spin.start(`uploading ${mediaAssets.length} static asset(s)`);
1015
+ try {
1016
+ const assetForm = new FormData();
1017
+ for (const ma of mediaAssets) {
1018
+ const fileBuf = fs.readFileSync(ma.localPath);
1019
+ assetForm.append("assets", new Blob([fileBuf], { type: ma.contentType }), path.basename(ma.localPath));
1020
+ }
1021
+ const assetUploadUrl = `${serverUrl}/api/assets/upload`;
1022
+ const assetRes = await fetch(assetUploadUrl, {
1023
+ method: "POST",
1024
+ headers: {
1025
+ Authorization: `Bearer ${token}`,
1026
+ ...projectId ? { "X-Project-Id": projectId } : {}
1027
+ },
1028
+ body: assetForm
1029
+ });
1030
+ if (assetRes.ok) {
1031
+ spin.stop(`synced ${mediaAssets.length} static asset(s)`);
1032
+ } else {
1033
+ spin.stop(`asset sync notice: server returned HTTP ${assetRes.status}`);
1034
+ }
1035
+ } catch (assetErr) {
1036
+ spin.stop(`asset sync: ${c.dim}${assetErr.message}${c.reset}`);
698
1037
  }
699
- // Save private key globally
700
- if (projectId) {
701
- savePrivateKey(projectId, keys.privateKey);
702
- // Update app.json
703
- updateAppJson(cwd, serverUrl, projectId, keys.publicKey);
704
- const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
705
- console.log(`\n${sep}`);
706
- console.log(` ${c.green}✓${c.reset} ${c.bold}Initialised${c.reset}`);
707
- console.log(sep);
708
- console.log(` project ${c.dim}${projectId}${c.reset}`);
709
- console.log(` server ${c.dim}${serverUrl}${c.reset}`);
710
- console.log(` key file ${c.dim}${path.join(GLOBAL_KEYS_DIR, `${projectId}.key`)}${c.reset}`);
711
- console.log(sep + "\n");
712
- console.log(` ${c.dim}Next step:${c.reset} run ${c.cyan}edge-ota push${c.reset} to publish your first update.\n`);
713
- }
714
- });
715
- // ──────────────────────────────────────────────────────────────────────────────
716
- // edge-ota push
717
- // ──────────────────────────────────────────────────────────────────────────────
718
- program
719
- .command("push")
720
- .description("Export your Expo bundle and publish an OTA update")
721
- .option("-c, --channel <channel>", "Deployment channel", "production")
722
- .option("-p, --platform <platform>", "Target platform: ios | android | all", "all")
723
- .option("--skip-export", "Skip expo export (use existing ./dist directory)")
724
- .option("--dry-run", "Build and sign the payload but do NOT upload")
725
- .action(async (options) => {
726
- const cwd = process.cwd();
727
- const token = resolveToken();
728
- printBanner();
729
- if (!token) {
730
- console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.`);
731
- console.error(` Or set ${c.dim}EDGE_OTA_TOKEN${c.reset} environment variable.\n`);
732
- process.exit(1);
1038
+ }
1039
+ const platforms = options.platform === "all" ? ["ios", "android"] : [options.platform];
1040
+ const sep = `${c.dim}${"\u2500".repeat(56)}${c.reset}`;
1041
+ for (const platform of platforms) {
1042
+ console.log(`
1043
+ ${sep}`);
1044
+ console.log(` ${c.bold}${platform}${c.reset}`);
1045
+ console.log(sep);
1046
+ const bundlePath = findBundle(distDir, platform);
1047
+ if (!bundlePath) {
1048
+ printWarningBox(
1049
+ `No bundle found for ${platform}`,
1050
+ `Expected bundle at ./dist/_expo/static/js/${platform}/ or ./dist/index.${platform}.hbc`,
1051
+ `If this platform is not supported in your app, ignore this or pass -p <platform>.`
1052
+ );
1053
+ continue;
733
1054
  }
734
- // Auto-detect from app.json
735
- const appCfg = readAppJson(cwd);
736
- const { serverUrl, projectId, runtimeVersion } = appCfg;
737
- // Load private signing key
738
- const privateKey = projectId ? loadPrivateKey(projectId) : null;
739
- if (!privateKey) {
740
- console.error(` ${c.red}✗${c.reset} No signing key found for this project.`);
741
- if (projectId) {
742
- console.error(` Expected at: ${c.dim}${path.join(GLOBAL_KEYS_DIR, `${projectId}.key`)}${c.reset}`);
743
- }
744
- console.error(` Run ${c.cyan}edge-ota init${c.reset} to set up code signing.\n`);
745
- process.exit(1);
1055
+ const bundleHash = await hashFile(bundlePath);
1056
+ const bundleSize = (fs.statSync(bundlePath).size / 1024 / 1024).toFixed(2);
1057
+ console.log(` bundle ${c.dim}${bundleHash.slice(0, 16)}\u2026 ${bundleSize} MB${c.reset}`);
1058
+ const payloadObj = {
1059
+ channel: options.channel,
1060
+ runtimeVersion,
1061
+ platform,
1062
+ bundleHash,
1063
+ timestamp: Date.now(),
1064
+ assets: mediaAssets.map((a) => ({
1065
+ hash: a.hash,
1066
+ key: a.key,
1067
+ fileExtension: path.extname(a.localPath),
1068
+ contentType: a.contentType
1069
+ })),
1070
+ assetCount: mediaAssets.length,
1071
+ publicKey: appCfg.publicKey
1072
+ };
1073
+ const payloadStr = JSON.stringify(payloadObj);
1074
+ spin.start("signing bundle hash");
1075
+ const signature = await signPayload(payloadStr, privateKey);
1076
+ spin.stop(`signed ${c.dim}${signature.slice(0, 20)}\u2026${c.reset}`);
1077
+ if (options.dryRun) {
1078
+ console.log(` ${c.yellow}dry-run${c.reset} upload skipped`);
1079
+ continue;
746
1080
  }
747
- const uploadUrl = projectId
748
- ? `${serverUrl}/api/projects/${projectId}/updates`
749
- : `${serverUrl}/api/updates`;
750
- const distDir = path.resolve(cwd, "dist");
751
- // ── Step 1: Expo export ──────────────────────────────────────────────────
752
- if (!options.skipExport) {
753
- spin.start("running expo export");
754
- await new Promise((resolve, reject) => {
755
- const proc = spawn("npx", ["expo", "export"], {
756
- cwd,
757
- stdio: ["inherit", "pipe", "pipe"],
758
- shell: process.platform === "win32",
759
- });
760
- // Filter stdout: only show platform bundle lines
761
- proc.stdout?.on("data", (chunk) => {
762
- const text = chunk.toString();
763
- for (const line of text.split("\n")) {
764
- const t = line.trim();
765
- // Show platform bundle summary lines only
766
- if (t.startsWith("_expo/static/js/") || t.match(/\.(hbc|js)\s+\(\d/)) {
767
- spin.stop();
768
- console.log(` ${c.dim}${t}${c.reset}`);
769
- spin.start("running expo export");
770
- }
771
- // Update spinner with bundling progress
772
- if (t.match(/Bundled \d+ms/)) {
773
- spin.update("expo export — " + t.replace("Bundled", "").trim());
774
- }
775
- }
776
- });
777
- // Suppress stderr noise but capture real errors
778
- let stderrBuf = "";
779
- proc.stderr?.on("data", (chunk) => { stderrBuf += chunk.toString(); });
780
- proc.on("close", code => {
781
- if (code === 0) {
782
- spin.stop("expo export complete");
783
- resolve();
784
- }
785
- else {
786
- spin.fail("expo export failed");
787
- // Print last few lines of stderr for context
788
- const lines = stderrBuf.split("\n").filter(Boolean);
789
- for (const l of lines.slice(-10))
790
- console.error(` ${c.dim}${l}${c.reset}`);
791
- reject(new Error(`expo export exited with code ${code}`));
792
- }
793
- });
794
- }).catch(() => process.exit(1));
1081
+ spin.start("uploading release to server");
1082
+ const bundleBuffer = fs.readFileSync(bundlePath);
1083
+ const form = new FormData();
1084
+ form.append("bundle", new Blob([bundleBuffer], { type: "application/javascript" }), `bundle-${platform}.hbc`);
1085
+ form.append("payload", payloadStr);
1086
+ form.append("signature", signature);
1087
+ form.append("platform", platform);
1088
+ let response;
1089
+ try {
1090
+ response = await fetch(uploadUrl, {
1091
+ method: "POST",
1092
+ headers: {
1093
+ Authorization: `Bearer ${token}`,
1094
+ ...projectId ? { "X-Project-Id": projectId } : {}
1095
+ },
1096
+ body: form
1097
+ });
1098
+ } catch (uploadNetErr) {
1099
+ spin.fail("Upload connection failed");
1100
+ const diag = diagnoseNetworkError(uploadNetErr, serverUrl);
1101
+ printErrorBox(diag.title, diag.reason, diag.solution);
1102
+ process.exit(1);
795
1103
  }
796
- else {
797
- console.log(` ${c.dim}skip-export — using existing ./dist${c.reset}`);
1104
+ if (!response.ok) {
1105
+ const text = await response.text();
1106
+ spin.fail(`Upload failed (HTTP ${response.status})`);
1107
+ if (response.status === 401) {
1108
+ printErrorBox(
1109
+ "Authentication expired",
1110
+ "Your session token is no longer valid.",
1111
+ `Run ${c.cyan}edge-ota login${c.reset} to refresh your credentials.`
1112
+ );
1113
+ } else if (response.status === 403) {
1114
+ printErrorBox(
1115
+ "Permission denied",
1116
+ text || `You do not have permission to publish updates to project ${projectId}.`,
1117
+ `Check your team role on ${c.bold}https://ota.renbo.site${c.reset} or verify API key permissions.`
1118
+ );
1119
+ } else if (response.status === 404) {
1120
+ printErrorBox(
1121
+ "Project not found on server",
1122
+ `Project ID "${projectId}" was not found at ${serverUrl}.`,
1123
+ `Run ${c.cyan}edge-ota init${c.reset} to re-associate with an existing project or create a new one.`
1124
+ );
1125
+ } else if (response.status === 413) {
1126
+ printErrorBox(
1127
+ "Payload too large",
1128
+ "The bundle file exceeds server upload size limits.",
1129
+ "Consider optimizing your bundle, removing large embedded media, or adjusting server body limit."
1130
+ );
1131
+ } else {
1132
+ printErrorBox(`Server returned HTTP ${response.status}`, text, "Check server logs or try again shortly.");
1133
+ }
1134
+ process.exit(1);
798
1135
  }
799
- if (!fs.existsSync(distDir)) {
800
- console.error(` ${c.red}✗${c.reset} ./dist not found after export\n`);
801
- process.exit(1);
1136
+ const body = await response.json().catch(() => ({}));
1137
+ spin.stop("uploaded");
1138
+ const runtimesDisplay = body.runtimeVersions ? body.runtimeVersions.join(", ") : runtimeVersion;
1139
+ console.log(` ${c.green}\u2713${c.reset} deployed`);
1140
+ console.log(` id ${c.dim}${body.updateId || "\u2014"}${c.reset}`);
1141
+ console.log(` channel ${options.channel}`);
1142
+ console.log(` runtime(s) ${runtimesDisplay}`);
1143
+ if (mediaAssets.length > 0) {
1144
+ console.log(` assets ${mediaAssets.length} static asset(s) linked`);
802
1145
  }
803
- // ── Step 2: Collect assets ───────────────────────────────────────────────
804
- spin.start("collecting assets");
805
- const assets = await collectAssets(distDir);
806
- spin.stop(`found ${assets.length} asset(s)`);
807
- const platforms = options.platform === "all" ? ["ios", "android"] : [options.platform];
808
- const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
809
- // ── Step 3: Per-platform upload ──────────────────────────────────────────
810
- for (const platform of platforms) {
811
- console.log(`\n${sep}`);
812
- console.log(` ${c.bold}${platform}${c.reset}`);
813
- console.log(sep);
814
- const bundlePath = findBundle(distDir, platform);
815
- if (!bundlePath) {
816
- console.warn(` ${c.yellow}⚠${c.reset} no bundle found for ${platform}, skipping`);
817
- continue;
818
- }
819
- const bundleHash = await hashFile(bundlePath);
820
- const bundleSize = (fs.statSync(bundlePath).size / 1024 / 1024).toFixed(2);
821
- console.log(` bundle ${c.dim}${bundleHash.slice(0, 16)}… ${bundleSize} MB${c.reset}`);
822
- const payloadObj = {
823
- channel: options.channel,
824
- runtimeVersion,
825
- platform,
826
- bundleHash,
827
- timestamp: Date.now(),
828
- assetCount: assets.length,
829
- publicKey: appCfg.publicKey,
830
- };
831
- const payloadStr = JSON.stringify(payloadObj);
832
- spin.start("signing");
833
- const signature = await signPayload(payloadStr, privateKey);
834
- spin.stop(`signed ${c.dim}${signature.slice(0, 20)}…${c.reset}`);
835
- if (options.dryRun) {
836
- console.log(` ${c.yellow}dry-run${c.reset} upload skipped`);
837
- continue;
838
- }
839
- spin.start("uploading");
840
- const bundleBuffer = fs.readFileSync(bundlePath);
841
- const form = new FormData();
842
- form.append("bundle", new Blob([bundleBuffer], { type: "application/javascript" }), `bundle-${platform}.hbc`);
843
- form.append("payload", payloadStr);
844
- form.append("signature", signature);
845
- form.append("platform", platform);
846
- const response = await fetch(uploadUrl, {
847
- method: "POST",
848
- headers: {
849
- Authorization: `Bearer ${token}`,
850
- ...(projectId ? { "X-Project-Id": projectId } : {})
851
- },
852
- body: form,
853
- }).catch(e => {
854
- spin.fail(`Upload failed: ${e.message}`);
855
- process.exit(1);
856
- });
857
- if (!response.ok) {
858
- const text = await response.text();
859
- spin.fail(`Upload failed (HTTP ${response.status}): ${text}`);
860
- process.exit(1);
861
- }
862
- const body = await response.json().catch(() => ({}));
863
- spin.stop("uploaded");
864
- console.log(` ${c.green}✓${c.reset} deployed`);
865
- console.log(` id ${c.dim}${body.updateId || "—"}${c.reset}`);
866
- console.log(` channel ${options.channel}`);
867
- console.log(` runtime ${runtimeVersion}`);
868
- }
869
- console.log(`\n${sep}`);
870
- console.log(` ${c.green}✓${c.reset} ${c.bold}done${c.reset} ${c.dim}update will be applied on next OTA sync${c.reset}`);
871
- console.log(`${sep}\n`);
1146
+ }
1147
+ console.log(`
1148
+ ${sep}`);
1149
+ console.log(` ${c.green}\u2713${c.reset} ${c.bold}done${c.reset} ${c.dim}update will be applied on next OTA sync${c.reset}`);
1150
+ console.log(`${sep}
1151
+ `);
1152
+ process.exit(0);
872
1153
  });
873
- // ──────────────────────────────────────────────────────────────────────────────
874
- // edge-ota status
875
- // ──────────────────────────────────────────────────────────────────────────────
876
- program
877
- .command("status")
878
- .description("List recent deployments for this project")
879
- .option("-n, --limit <n>", "Number of releases to show", "10")
880
- .action(async (options) => {
881
- printBanner();
882
- const token = resolveToken();
883
- if (!token) {
884
- console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.\n`);
885
- process.exit(1);
886
- }
887
- const cwd = process.cwd();
888
- const appCfg = readAppJson(cwd);
889
- const { serverUrl, projectId } = appCfg;
890
- const headers = { Authorization: `Bearer ${token}` };
891
- if (projectId)
892
- headers["x-project-id"] = projectId;
893
- spin.start("fetching releases");
894
- const res = await fetch(`${serverUrl}/api/releases`, { headers }).catch(e => {
895
- spin.fail(`Connection failed: ${e.message}`);
896
- process.exit(1);
897
- });
898
- if (!res.ok) {
899
- spin.fail(`Failed to fetch releases (HTTP ${res.status})`);
900
- process.exit(1);
901
- }
902
- const releases = await res.json();
903
- spin.stop();
904
- if (!releases.length) {
905
- console.log(` ${c.dim}No releases found.${c.reset}\n`);
906
- return;
907
- }
908
- const limit = parseInt(options.limit);
909
- const rows = releases.slice(0, limit).map((r) => ({
910
- ID: r.id?.slice(0, 8) ?? "—",
911
- Channel: r.channel ?? "—",
912
- Runtime: r.runtime ?? "—",
913
- Platform: r.platform ?? "all",
914
- Created: r.created_at ? new Date(r.created_at).toLocaleString() : "—",
915
- }));
916
- console.table(rows);
1154
+ program.command("status").description("List recent deployments and active release status for this project").option("-n, --limit <n>", "Number of releases to show", "10").addHelpText("after", `
1155
+ ${c.bold}Examples:${c.reset}
1156
+ $ ${c.cyan}edge-ota status${c.reset} Show the last 10 releases
1157
+ $ ${c.cyan}edge-ota status -n 25${c.reset} Show the last 25 releases
1158
+ `).action(async (options) => {
1159
+ printBanner();
1160
+ const token = resolveToken();
1161
+ if (!token) {
1162
+ printErrorBox(
1163
+ "Not logged in",
1164
+ "Authentication is required to query release status.",
1165
+ `Run ${c.cyan}edge-ota login${c.reset} first.`
1166
+ );
1167
+ process.exit(1);
1168
+ }
1169
+ const cwd = process.cwd();
1170
+ const appCfg = readAppJson(cwd);
1171
+ const { serverUrl, projectId } = appCfg;
1172
+ const headers = { Authorization: `Bearer ${token}` };
1173
+ if (projectId) headers["x-project-id"] = projectId;
1174
+ spin.start("fetching releases from server");
1175
+ let res;
1176
+ try {
1177
+ res = await fetch(`${serverUrl}/api/releases`, { headers });
1178
+ } catch (e) {
1179
+ spin.fail("Connection failed");
1180
+ const diag = diagnoseNetworkError(e, serverUrl);
1181
+ printErrorBox(diag.title, diag.reason, diag.solution);
1182
+ process.exit(1);
1183
+ }
1184
+ if (!res.ok) {
1185
+ spin.fail(`Failed to fetch releases (HTTP ${res.status})`);
1186
+ printErrorBox(
1187
+ `Server returned HTTP ${res.status}`,
1188
+ res.statusText,
1189
+ `Verify that project "${projectId}" exists and your token has view permissions.`
1190
+ );
1191
+ process.exit(1);
1192
+ }
1193
+ const releases = await res.json();
1194
+ spin.stop();
1195
+ if (!releases.length) {
1196
+ console.log(` ${c.dim}No releases found for project "${projectId}".${c.reset}`);
1197
+ console.log(` Run ${c.cyan}edge-ota push${c.reset} to publish your first OTA release!
1198
+ `);
1199
+ process.exit(0);
1200
+ }
1201
+ const limit = parseInt(options.limit);
1202
+ const rows = releases.slice(0, limit).map((r) => ({
1203
+ ID: r.id?.slice(0, 8) ?? "\u2014",
1204
+ Channel: r.channel ?? "\u2014",
1205
+ Runtime: r.runtime ?? "\u2014",
1206
+ Platform: r.platform ?? "all",
1207
+ Created: r.created_at ? new Date(r.created_at).toLocaleString() : "\u2014"
1208
+ }));
1209
+ console.table(rows);
1210
+ console.log();
1211
+ process.exit(0);
917
1212
  });
918
- // ──────────────────────────────────────────────────────────────────────────────
919
- // edge-ota keygen
920
- // ──────────────────────────────────────────────────────────────────────────────
921
- program
922
- .command("keygen")
923
- .description("Generate a fresh ECDSA P-256 key pair (prints to stdout, does not write files)")
924
- .action(async () => {
925
- const keys = await generateECDSAKeyPair();
926
- const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
927
- console.log(`\n${sep}`);
928
- console.log(` ${c.bold}private key${c.reset} ${c.dim}keep secret — never commit${c.reset}`);
929
- console.log(sep);
930
- console.log(keys.privateKey);
931
- console.log(`\n${sep}`);
932
- console.log(` ${c.bold}public key${c.reset} ${c.dim}paste into dashboard → Settings → General${c.reset}`);
933
- console.log(sep);
934
- console.log(keys.publicKey);
1213
+ program.command("keygen").description("Generate a fresh ECDSA P-256 key pair for code signing").addHelpText("after", `
1214
+ ${c.bold}What to do with these keys:${c.reset}
1215
+ 1. ${c.bold}Private Key:${c.reset} Store securely in ${c.dim}~/.config/edge-ota/keys/<projectId>.key${c.reset}
1216
+ or set in your CI/CD secrets for automated pushes.
1217
+ 2. ${c.bold}Public Key:${c.reset} Save in your project settings on the dashboard (${DEFAULT_SERVER})
1218
+ or paste into ${c.dim}app.json${c.reset} under ${c.dim}expo.extra.edgeOtaPublicKey${c.reset}.
1219
+ `).action(async () => {
1220
+ const keys = await generateECDSAKeyPair();
1221
+ const sep = `${c.dim}${"\u2500".repeat(56)}${c.reset}`;
1222
+ console.log(`
1223
+ ${sep}`);
1224
+ console.log(` ${c.bold}PRIVATE KEY${c.reset} ${c.red}(Keep Secret \u2014 Never Commit to Git)${c.reset}`);
1225
+ console.log(sep);
1226
+ console.log(keys.privateKey);
1227
+ console.log(`
1228
+ ${sep}`);
1229
+ console.log(` ${c.bold}PUBLIC KEY${c.reset} ${c.green}(Paste into Dashboard \u2192 Settings \u2192 Code Signing)${c.reset}`);
1230
+ console.log(sep);
1231
+ console.log(keys.publicKey);
1232
+ console.log(`${sep}
1233
+ `);
1234
+ process.exit(0);
935
1235
  });
936
1236
  await checkAndAutoUpdate();
937
1237
  program.parse(process.argv);