@renbostudios/edge-ota 0.4.0 → 0.4.3

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,963 +8,1270 @@ 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
- let 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
- // 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
- }
275
- // ── Updates URL ──
276
- const updatesUrl = expo?.updates?.url ?? "";
277
- if (!updatesUrl) {
278
- console.error(` ${c.red}✗${c.reset} ${c.bold}expo.updates.url${c.reset} is not configured in app.json`);
279
- console.error(` Run ${c.cyan}edge-ota init${c.reset} to set this up.\n`);
280
- process.exit(1);
281
- }
282
- // Parse serverUrl and projectId from the updates URL.
283
- // Expected format: https://<host>/api/projects/<uuid>/updates
284
- // or: https://<host>/api/updates
285
- let serverUrl;
286
- let projectId = null;
287
- try {
288
- const u = new URL(updatesUrl);
289
- const projectMatch = u.pathname.match(/\/api\/projects\/([^/]+)\/updates/);
290
- if (projectMatch) {
291
- projectId = projectMatch[1];
292
- serverUrl = `${u.protocol}//${u.host}`;
293
- }
294
- else {
295
- serverUrl = `${u.protocol}//${u.host}`;
296
- }
297
- }
298
- catch {
299
- console.error(` ${c.red}✗${c.reset} Could not parse ${c.dim}expo.updates.url${c.reset} in app.json`);
300
- 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;
349
+ if (typeof expo.runtimeVersion === "string") {
350
+ runtimeVersion = expo.runtimeVersion;
351
+ } else if (typeof expo.runtimeVersion === "object" && expo.runtimeVersion !== null && expo.runtimeVersion.policy === "appVersion") {
352
+ if (typeof expo.version === "string") {
353
+ runtimeVersion = expo.version;
354
+ } else {
355
+ printErrorBox(
356
+ "Missing expo.version in app.json",
357
+ `"expo.runtimeVersion.policy" is set to "appVersion", but "expo.version" is not defined in app.json.`,
358
+ `Add ${c.cyan}"version": "1.0.0"${c.reset} to your ${c.bold}app.json${c.reset} under ${c.dim}"expo"${c.reset}.`
359
+ );
360
+ process.exit(1);
301
361
  }
302
- // ── Project-level server override ──
303
- // expo.extra.edgeOtaServer lets users point a specific project at a
304
- // different (self-hosted) EdgeOTA instance without re-logging in.
305
- const projectServer = expo?.extra?.edgeOtaServer;
306
- if (projectServer) {
307
- serverUrl = projectServer.replace(/\/$/, "");
362
+ } else if (!expo.runtimeVersion) {
363
+ printErrorBox(
364
+ "Missing expo.runtimeVersion in app.json",
365
+ `No "runtimeVersion" field found under "expo" in app.json.`,
366
+ [
367
+ `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}`,
368
+ `Or use the appVersion policy: ${c.cyan}"runtimeVersion": { "policy": "appVersion" }${c.reset}`
369
+ ]
370
+ );
371
+ process.exit(1);
372
+ } else {
373
+ printErrorBox(
374
+ "Unsupported expo.runtimeVersion in app.json",
375
+ `expo.runtimeVersion in app.json resolved to: ${JSON.stringify(runtimeVersion)}`,
376
+ [
377
+ `EdgeOTA requires either a string (e.g. "1.0.0") or { "policy": "appVersion" }.`,
378
+ `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.`
379
+ ]
380
+ );
381
+ process.exit(1);
382
+ }
383
+ const updatesUrl = expo?.updates?.url ?? "";
384
+ if (!updatesUrl) {
385
+ printErrorBox(
386
+ "Missing expo.updates.url in app.json",
387
+ "Your app is not configured to receive updates from an EdgeOTA server.",
388
+ [
389
+ `Run ${c.cyan}edge-ota init${c.reset} in this directory to automatically configure your project.`,
390
+ `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}`
391
+ ]
392
+ );
393
+ process.exit(1);
394
+ }
395
+ let serverUrl;
396
+ let projectId = null;
397
+ try {
398
+ const u = new URL(updatesUrl);
399
+ const projectMatch = u.pathname.match(/\/api\/projects\/([^/]+)\/updates/);
400
+ if (projectMatch) {
401
+ projectId = projectMatch[1];
402
+ serverUrl = `${u.protocol}//${u.host}`;
403
+ } else {
404
+ serverUrl = `${u.protocol}//${u.host}`;
308
405
  }
309
- return { serverUrl, projectId, runtimeVersion, publicKey: expo?.extra?.edgeOtaPublicKey };
406
+ } catch {
407
+ printErrorBox(
408
+ "Malformed expo.updates.url",
409
+ `The URL "${updatesUrl}" in app.json is not a valid HTTP/HTTPS URL.`,
410
+ `Run ${c.cyan}edge-ota init${c.reset} to generate a valid updates URL.`
411
+ );
412
+ process.exit(1);
413
+ }
414
+ const projectServer = expo?.extra?.edgeOtaServer;
415
+ if (projectServer) {
416
+ serverUrl = projectServer.replace(/\/$/, "");
417
+ }
418
+ return {
419
+ serverUrl,
420
+ projectId,
421
+ runtimeVersion,
422
+ publicKey: expo?.extra?.edgeOtaPublicKey,
423
+ expoConfig: expo
424
+ };
310
425
  }
311
426
  function updateAppJson(cwd, serverUrl, projectId, publicKey) {
312
- const appJsonPath = path.resolve(cwd, "app.json");
313
- if (!fs.existsSync(appJsonPath))
314
- return;
315
- try {
316
- const data = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
317
- if (!data.expo)
318
- data.expo = {};
319
- if (!data.expo.updates)
320
- data.expo.updates = {};
321
- if (!data.expo.extra)
322
- data.expo.extra = {};
323
- const cleanUrl = serverUrl.replace(/\/$/, "");
324
- data.expo.updates.url = projectId
325
- ? `${cleanUrl}/api/projects/${projectId}/updates`
326
- : `${cleanUrl}/api/updates`;
327
- data.expo.updates.checkAutomatically = data.expo.updates.checkAutomatically ?? "ON_LOAD";
328
- data.expo.updates.fallbackToCacheTimeout = data.expo.updates.fallbackToCacheTimeout ?? 30000;
329
- data.expo.updates.requestHeaders = data.expo.updates.requestHeaders ?? { "expo-channel-name": "production" };
330
- // Store the server URL in expo.extra.edgeOtaServer so users can easily
331
- // override which EdgeOTA instance this project points to without re-running
332
- // `edge-ota login`. They can edit this value directly in app.json.
333
- data.expo.extra.edgeOtaServer = cleanUrl;
334
- if (publicKey) {
335
- data.expo.extra.edgeOtaPublicKey = publicKey;
336
- }
337
- fs.writeFileSync(appJsonPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
338
- console.log(` ${c.dim}app.json${c.reset} updated ${c.dim}expo.updates.url${c.reset} + ${c.dim}expo.extra.edgeOtaServer${c.reset}`);
339
- }
340
- catch (e) {
341
- console.error(` ${c.yellow}⚠${c.reset} Could not update app.json: ${e.message}`);
427
+ const appJsonPath = path.resolve(cwd, "app.json");
428
+ if (!fs.existsSync(appJsonPath)) return;
429
+ try {
430
+ const data = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
431
+ if (!data.expo) data.expo = {};
432
+ if (!data.expo.updates) data.expo.updates = {};
433
+ if (!data.expo.extra) data.expo.extra = {};
434
+ const cleanUrl = serverUrl.replace(/\/$/, "");
435
+ data.expo.updates.url = projectId ? `${cleanUrl}/api/projects/${projectId}/updates` : `${cleanUrl}/api/updates`;
436
+ data.expo.updates.checkAutomatically = data.expo.updates.checkAutomatically ?? "ON_LOAD";
437
+ data.expo.updates.fallbackToCacheTimeout = data.expo.updates.fallbackToCacheTimeout ?? 3e4;
438
+ data.expo.updates.requestHeaders = data.expo.updates.requestHeaders ?? { "expo-channel-name": "production" };
439
+ data.expo.extra.edgeOtaServer = cleanUrl;
440
+ if (publicKey) {
441
+ data.expo.extra.edgeOtaPublicKey = publicKey;
342
442
  }
443
+ fs.writeFileSync(appJsonPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
444
+ console.log(` ${c.dim}app.json${c.reset} updated ${c.dim}expo.updates.url${c.reset} + ${c.dim}expo.extra.edgeOtaServer${c.reset}`);
445
+ } catch (e) {
446
+ printWarningBox("Could not write to app.json", e.message, "Ensure app.json is writable.");
447
+ }
343
448
  }
344
- // ─── Readline helper ─────────────────────────────────────────────────────────
345
449
  function ask(query) {
346
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
347
- return new Promise(resolve => rl.question(query, a => { rl.close(); resolve(a.trim()); }));
450
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
451
+ return new Promise((resolve) => rl.question(query, (a) => {
452
+ rl.close();
453
+ resolve(a.trim());
454
+ }));
348
455
  }
349
456
  function askSecret(query) {
350
- return new Promise(resolve => {
351
- if (process.stdin.isTTY) {
352
- process.stdout.write(query);
353
- let input = "";
354
- process.stdin.setRawMode(true);
355
- process.stdin.resume();
356
- process.stdin.setEncoding("utf8");
357
- process.stdin.on("data", function onData(ch) {
358
- if (ch === "\r" || ch === "\n") {
359
- process.stdin.setRawMode(false);
360
- process.stdin.removeListener("data", onData);
361
- process.stdout.write("\n");
362
- resolve(input);
363
- }
364
- else if (ch === "\u0003") {
365
- process.exit(0);
366
- }
367
- else if (ch === "\u007f") {
368
- if (input.length > 0) {
369
- input = input.slice(0, -1);
370
- process.stdout.write("\b \b");
371
- }
372
- }
373
- else {
374
- input += ch;
375
- process.stdout.write("•");
376
- }
377
- });
378
- }
379
- else {
380
- ask(query).then(resolve);
457
+ return new Promise((resolve) => {
458
+ if (process.stdin.isTTY) {
459
+ process.stdout.write(query);
460
+ let input = "";
461
+ process.stdin.setRawMode(true);
462
+ process.stdin.resume();
463
+ process.stdin.setEncoding("utf8");
464
+ process.stdin.on("data", function onData(ch) {
465
+ if (ch === "\r" || ch === "\n") {
466
+ process.stdin.setRawMode(false);
467
+ process.stdin.removeListener("data", onData);
468
+ process.stdout.write("\n");
469
+ resolve(input);
470
+ } else if (ch === "") {
471
+ process.exit(0);
472
+ } else if (ch === "\x7F") {
473
+ if (input.length > 0) {
474
+ input = input.slice(0, -1);
475
+ process.stdout.write("\b \b");
476
+ }
477
+ } else {
478
+ input += ch;
479
+ process.stdout.write("\u2022");
381
480
  }
382
- });
481
+ });
482
+ } else {
483
+ ask(query).then(resolve);
484
+ }
485
+ });
383
486
  }
384
487
  function selectOption(question, options) {
385
- return new Promise((resolve) => {
386
- if (!process.stdin.isTTY) {
387
- console.log(` ${c.bold}${question}${c.reset}`);
388
- options.forEach((opt, idx) => {
389
- console.log(` [${idx + 1}] ${opt.label}`);
390
- });
391
- ask(` selection (1-${options.length}): `).then((choice) => {
392
- const idx = parseInt(choice) - 1;
393
- if (!isNaN(idx) && idx >= 0 && idx < options.length) {
394
- resolve(options[idx].value);
395
- }
396
- else {
397
- resolve(options[0].value);
398
- }
399
- });
400
- return;
488
+ return new Promise((resolve) => {
489
+ if (!process.stdin.isTTY) {
490
+ console.log(` ${c.bold}${question}${c.reset}`);
491
+ options.forEach((opt, idx) => {
492
+ console.log(` [${idx + 1}] ${opt.label}`);
493
+ });
494
+ ask(` selection (1-${options.length}): `).then((choice) => {
495
+ const idx = parseInt(choice) - 1;
496
+ if (!isNaN(idx) && idx >= 0 && idx < options.length) {
497
+ resolve(options[idx].value);
498
+ } else {
499
+ resolve(options[0].value);
401
500
  }
402
- let cursor = 0;
403
- const hideCursor = "\u001b[?25l";
404
- const showCursor = "\u001b[?25h";
405
- let hasRendered = false;
406
- console.log(` ${c.bold}${question}${c.reset}`);
407
- function render() {
408
- if (hasRendered) {
409
- for (let i = 0; i < options.length; i++) {
410
- process.stdout.write("\r\u001b[K\u001b[A");
411
- }
412
- process.stdout.write("\r\u001b[K");
413
- }
414
- hasRendered = true;
415
- options.forEach((opt, idx) => {
416
- const isSelected = idx === cursor;
417
- const marker = isSelected ? `${c.cyan}❯${c.reset}` : " ";
418
- const text = isSelected ? `${c.bold}${c.cyan}${opt.label}${c.reset}` : `${c.dim}${opt.label}${c.reset}`;
419
- process.stdout.write(` ${marker} ${text}\n`);
420
- });
501
+ });
502
+ return;
503
+ }
504
+ let cursor = 0;
505
+ const hideCursor = "\x1B[?25l";
506
+ const showCursor = "\x1B[?25h";
507
+ let hasRendered = false;
508
+ console.log(` ${c.bold}${question}${c.reset}`);
509
+ function render() {
510
+ if (hasRendered) {
511
+ for (let i = 0; i < options.length; i++) {
512
+ process.stdout.write("\r\x1B[K\x1B[A");
421
513
  }
422
- readline.emitKeypressEvents(process.stdin);
423
- process.stdin.setRawMode(true);
424
- process.stdin.resume();
425
- process.stdout.write(hideCursor);
514
+ process.stdout.write("\r\x1B[K");
515
+ }
516
+ hasRendered = true;
517
+ options.forEach((opt, idx) => {
518
+ const isSelected = idx === cursor;
519
+ const marker = isSelected ? `${c.cyan}\u276F${c.reset}` : " ";
520
+ const text = isSelected ? `${c.bold}${c.cyan}${opt.label}${c.reset}` : `${c.dim}${opt.label}${c.reset}`;
521
+ process.stdout.write(` ${marker} ${text}
522
+ `);
523
+ });
524
+ }
525
+ readline.emitKeypressEvents(process.stdin);
526
+ process.stdin.setRawMode(true);
527
+ process.stdin.resume();
528
+ process.stdout.write(hideCursor);
529
+ render();
530
+ function onKeypress(str, key) {
531
+ if (key.ctrl && key.name === "c") {
532
+ process.stdout.write(showCursor);
533
+ process.stdin.setRawMode(false);
534
+ process.stdin.pause();
535
+ process.exit(0);
536
+ }
537
+ if (key.name === "up" || key.name === "k") {
538
+ cursor = (cursor - 1 + options.length) % options.length;
426
539
  render();
427
- function onKeypress(str, key) {
428
- if (key.ctrl && key.name === "c") {
429
- process.stdout.write(showCursor);
430
- process.stdin.setRawMode(false);
431
- process.stdin.pause();
432
- process.exit(0);
433
- }
434
- if (key.name === "up" || key.name === "k") {
435
- cursor = (cursor - 1 + options.length) % options.length;
436
- render();
437
- }
438
- else if (key.name === "down" || key.name === "j") {
439
- cursor = (cursor + 1) % options.length;
440
- render();
441
- }
442
- else if (key.name === "return" || key.name === "enter") {
443
- process.stdout.write(showCursor);
444
- process.stdin.setRawMode(false);
445
- process.stdin.removeListener("keypress", onKeypress);
446
- process.stdin.pause();
447
- resolve(options[cursor].value);
448
- }
449
- }
450
- process.stdin.on("keypress", onKeypress);
451
- });
540
+ } else if (key.name === "down" || key.name === "j") {
541
+ cursor = (cursor + 1) % options.length;
542
+ render();
543
+ } else if (key.name === "return" || key.name === "enter") {
544
+ process.stdout.write(showCursor);
545
+ process.stdin.setRawMode(false);
546
+ process.stdin.removeListener("keypress", onKeypress);
547
+ process.stdin.pause();
548
+ resolve(options[cursor].value);
549
+ }
550
+ }
551
+ process.stdin.on("keypress", onKeypress);
552
+ });
452
553
  }
453
- const CONTENT_TYPES = {
454
- ".js": "application/javascript",
455
- ".hbc": "application/javascript",
456
- ".json": "application/json",
457
- ".png": "image/png",
458
- ".jpg": "image/jpeg",
459
- ".jpeg": "image/jpeg",
460
- ".gif": "image/gif",
461
- ".svg": "image/svg+xml",
462
- ".ttf": "font/ttf",
463
- ".otf": "font/otf",
464
- ".woff": "font/woff",
465
- ".woff2": "font/woff2",
466
- ".mp4": "video/mp4",
467
- ".webm": "video/webm",
554
+ var CONTENT_TYPES = {
555
+ ".js": "application/javascript",
556
+ ".hbc": "application/javascript",
557
+ ".json": "application/json",
558
+ ".png": "image/png",
559
+ ".jpg": "image/jpeg",
560
+ ".jpeg": "image/jpeg",
561
+ ".gif": "image/gif",
562
+ ".svg": "image/svg+xml",
563
+ ".ttf": "font/ttf",
564
+ ".otf": "font/otf",
565
+ ".woff": "font/woff",
566
+ ".woff2": "font/woff2",
567
+ ".mp4": "video/mp4",
568
+ ".webm": "video/webm"
468
569
  };
469
570
  async function hashFile(filePath) {
470
- const buffer = fs.readFileSync(filePath);
471
- return sha256Hex(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
571
+ const buffer = fs.readFileSync(filePath);
572
+ return sha256Hex(buffer.buffer.slice(
573
+ buffer.byteOffset,
574
+ buffer.byteOffset + buffer.byteLength
575
+ ));
472
576
  }
473
577
  async function collectAssets(distDir) {
474
- const entries = [];
475
- function walk(dir) {
476
- for (const name of fs.readdirSync(dir)) {
477
- const full = path.join(dir, name);
478
- if (fs.statSync(full).isDirectory()) {
479
- walk(full);
480
- }
481
- else {
482
- const ext = path.extname(name).toLowerCase();
483
- entries.push({
484
- localPath: full,
485
- key: path.relative(distDir, full).replace(/\\/g, "/"),
486
- contentType: CONTENT_TYPES[ext] || "application/octet-stream",
487
- hash: ""
488
- });
578
+ const entries = [];
579
+ const extMap = {};
580
+ const metadataPath = path.join(distDir, "metadata.json");
581
+ if (fs.existsSync(metadataPath)) {
582
+ try {
583
+ const meta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
584
+ if (meta.fileMetadata) {
585
+ for (const p of Object.keys(meta.fileMetadata)) {
586
+ const pMeta = meta.fileMetadata[p];
587
+ if (Array.isArray(pMeta.assets)) {
588
+ for (const a of pMeta.assets) {
589
+ if (a.path && a.ext) {
590
+ const normPath = a.path.replace(/\\/g, "/");
591
+ extMap[normPath] = a.ext.startsWith(".") ? a.ext.toLowerCase() : `.${a.ext.toLowerCase()}`;
592
+ }
489
593
  }
594
+ }
490
595
  }
596
+ }
597
+ } catch {
491
598
  }
492
- walk(distDir);
493
- await Promise.all(entries.map(async (e) => { e.hash = await hashFile(e.localPath); }));
494
- return entries;
599
+ }
600
+ function walk(dir) {
601
+ for (const name of fs.readdirSync(dir)) {
602
+ const full = path.join(dir, name);
603
+ if (fs.statSync(full).isDirectory()) {
604
+ walk(full);
605
+ } else {
606
+ const relKey = path.relative(distDir, full).replace(/\\/g, "/");
607
+ let ext = path.extname(name).toLowerCase();
608
+ if (!ext && extMap[relKey]) {
609
+ ext = extMap[relKey];
610
+ }
611
+ entries.push({
612
+ localPath: full,
613
+ key: relKey,
614
+ fileExtension: ext,
615
+ contentType: CONTENT_TYPES[ext] || "application/octet-stream",
616
+ hash: ""
617
+ });
618
+ }
619
+ }
620
+ }
621
+ walk(distDir);
622
+ await Promise.all(entries.map(async (e) => {
623
+ e.hash = await hashFile(e.localPath);
624
+ }));
625
+ return entries;
495
626
  }
496
627
  function findBundle(distDir, platform) {
497
- const expoStaticJs = path.join(distDir, "_expo", "static", "js", platform);
498
- if (fs.existsSync(expoStaticJs)) {
499
- const files = fs.readdirSync(expoStaticJs).filter(f => f.endsWith(".js") || f.endsWith(".hbc"));
500
- if (files.length)
501
- return path.join(expoStaticJs, files[0]);
502
- }
503
- const flatJs = path.join(distDir, `index.${platform}.js`);
504
- if (fs.existsSync(flatJs))
505
- return flatJs;
506
- const flatHbc = path.join(distDir, `index.${platform}.hbc`);
507
- if (fs.existsSync(flatHbc))
508
- return flatHbc;
509
- const rootJs = fs.readdirSync(distDir).find(f => f.endsWith(".js") || f.endsWith(".hbc"));
510
- if (rootJs)
511
- return path.join(distDir, rootJs);
512
- return null;
628
+ const expoStaticJs = path.join(distDir, "_expo", "static", "js", platform);
629
+ if (fs.existsSync(expoStaticJs)) {
630
+ const files = fs.readdirSync(expoStaticJs).filter((f) => f.endsWith(".js") || f.endsWith(".hbc"));
631
+ if (files.length) return path.join(expoStaticJs, files[0]);
632
+ }
633
+ const flatJs = path.join(distDir, `index.${platform}.js`);
634
+ if (fs.existsSync(flatJs)) return flatJs;
635
+ const flatHbc = path.join(distDir, `index.${platform}.hbc`);
636
+ if (fs.existsSync(flatHbc)) return flatHbc;
637
+ const rootJs = fs.readdirSync(distDir).find((f) => f.endsWith(".js") || f.endsWith(".hbc"));
638
+ if (rootJs) return path.join(distDir, rootJs);
639
+ return null;
513
640
  }
514
- // ─── Auth token resolution ────────────────────────────────────────────────────
515
641
  function resolveToken() {
516
- // 1. Global config
517
- const cfg = loadGlobalConfig();
518
- if (cfg?.token)
519
- return cfg.token;
520
- // 2. Env var fallback
521
- return process.env.EDGE_OTA_TOKEN || null;
642
+ if (process.env.EDGE_OTA_TOKEN) return process.env.EDGE_OTA_TOKEN;
643
+ const cfg = loadGlobalConfig();
644
+ if (cfg?.token) return cfg.token;
645
+ return null;
522
646
  }
523
- // ─── Program ─────────────────────────────────────────────────────────────────
524
- const program = new Command();
525
- program
526
- .name("edge-ota")
527
- .description("Zero-SDK OTA update platform for Expo — by Renbo Studios")
528
- .version(packageVersion);
529
- // Show help + auth status when called with no arguments
647
+ var program = new Command();
648
+ program.name("edge-ota").description("Zero-SDK, self-hostable OTA update platform for Expo \u2014 by Renbo Studios").version(packageVersion);
649
+ program.addHelpText("after", `
650
+ ${c.bold}Quick Start:${c.reset}
651
+ 1. ${c.cyan}edge-ota login${c.reset} Authenticate with EdgeOTA
652
+ 2. ${c.cyan}edge-ota init${c.reset} Configure your Expo app and generate signing keys
653
+ 3. ${c.cyan}npx expo prebuild --clean${c.reset} Bake the server URL into native files (Required!)
654
+ 4. ${c.cyan}edge-ota push${c.reset} Export and publish your first OTA update
655
+
656
+ ${c.bold}Documentation & Support:${c.reset}
657
+ Website: https://ota.renbo.site
658
+ Studio: https://renbostudios.com
659
+ GitHub: https://github.com/renbostudios/edge-ota
660
+ `);
530
661
  program.action(() => {
531
- printBanner();
532
- const cfg = loadGlobalConfig();
533
- if (cfg?.email) {
534
- console.log(` ${c.green}●${c.reset} Logged in as ${c.bold}${cfg.email}${c.reset} ${c.dim}(${cfg.serverUrl})${c.reset}`);
535
- }
536
- else {
537
- console.log(` ${c.dim}○ Not logged in — run ${c.reset}${c.cyan}edge-ota login${c.reset}`);
538
- }
539
- console.log();
540
- console.log(` ${c.bold}Commands${c.reset}`);
541
- const cmds = [
542
- ["login", "Authenticate and save credentials globally"],
543
- ["logout", "Remove stored credentials"],
544
- ["init", "Register project, generate signing keys, configure app.json"],
545
- ["push", "Export bundle and publish an OTA update"],
546
- ["status", "List recent deployments for this project"],
547
- ["keygen", "Generate a new ECDSA key pair (prints to stdout)"],
548
- ];
549
- for (const [cmd, desc] of cmds) {
550
- console.log(` ${c.cyan}${cmd.padEnd(10)}${c.reset} ${c.dim}${desc}${c.reset}`);
551
- }
552
- console.log();
553
- console.log(` Run ${c.cyan}edge-ota <command> --help${c.reset} for command-specific options.`);
554
- console.log();
662
+ printBanner();
663
+ const cfg = loadGlobalConfig();
664
+ if (cfg?.email) {
665
+ console.log(` ${c.green}\u25CF${c.reset} Logged in as ${c.bold}${cfg.email}${c.reset} ${c.dim}(${cfg.serverUrl})${c.reset}`);
666
+ } else {
667
+ console.log(` ${c.dim}\u25CB Not logged in \u2014 run ${c.reset}${c.cyan}edge-ota login${c.reset}`);
668
+ }
669
+ console.log();
670
+ console.log(` ${c.bold}Commands${c.reset}`);
671
+ const cmds = [
672
+ ["login", "Authenticate and save credentials globally"],
673
+ ["logout", "Remove stored credentials"],
674
+ ["init", "Register project, generate signing keys, configure app.json"],
675
+ ["push", "Export bundle and publish an OTA update"],
676
+ ["status", "List recent deployments for this project"],
677
+ ["keygen", "Generate a new ECDSA key pair (prints to stdout)"]
678
+ ];
679
+ for (const [cmd, desc] of cmds) {
680
+ console.log(` ${c.cyan}${cmd.padEnd(10)}${c.reset} ${c.dim}${desc}${c.reset}`);
681
+ }
682
+ console.log();
683
+ console.log(` Run ${c.cyan}edge-ota <command> --help${c.reset} for detailed command options and examples.`);
684
+ console.log();
555
685
  });
556
- // ──────────────────────────────────────────────────────────────────────────────
557
- // edge-ota login
558
- // ──────────────────────────────────────────────────────────────────────────────
559
- program
560
- .command("login")
561
- .description("Authenticate with your EdgeOTA account")
562
- .option("-s, --server <url>", `EdgeOTA server URL (default: ${DEFAULT_SERVER})`)
563
- .action(async (options) => {
564
- printBanner();
565
- const usingCustomServer = options.server && options.server !== DEFAULT_SERVER;
566
- console.log(` ${c.bold}Sign in to EdgeOTA${c.reset}`);
567
- if (usingCustomServer) {
568
- console.log(` ${c.dim}server: ${options.server}${c.reset}`);
569
- }
570
- console.log();
571
- const serverUrl = (options.server || DEFAULT_SERVER).replace(/\/$/, "");
572
- const email = await ask(` ${c.dim}email:${c.reset} `);
573
- const password = await askSecret(` ${c.dim}password:${c.reset} `);
574
- spin.start("authenticating");
575
- try {
576
- const res = await fetch(`${serverUrl}/api/auth/login`, {
577
- method: "POST",
578
- headers: { "Content-Type": "application/json" },
579
- body: JSON.stringify({ email, password }),
580
- });
581
- if (!res.ok) {
582
- const text = await res.text();
583
- spin.fail(`Login failed: ${text || res.statusText}`);
584
- process.exit(1);
585
- }
586
- const data = await res.json();
587
- saveGlobalConfig({ token: data.token, email: data.email, serverUrl });
588
- spin.stop();
589
- const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
590
- console.log(sep);
591
- console.log(` ${c.green}✓${c.reset} Logged in as ${c.bold}${data.email}${c.reset}`);
592
- console.log(` ${c.dim}credentials saved to ${GLOBAL_CONFIG_FILE}${c.reset}`);
593
- console.log(sep + "\n");
594
- process.exit(0);
595
- }
596
- catch (e) {
597
- spin.fail(`Connection failed: ${e.message}`);
598
- process.exit(1);
686
+ program.command("login").description("Authenticate with your EdgeOTA account and save credentials globally").option("-s, --server <url>", "EdgeOTA server URL", DEFAULT_SERVER).addHelpText("after", `
687
+ ${c.bold}Examples:${c.reset}
688
+ $ ${c.cyan}edge-ota login${c.reset} Sign in to standard cloud service (${DEFAULT_SERVER})
689
+ $ ${c.cyan}edge-ota login -s http://localhost:3020${c.reset} Sign in to a self-hosted local server
690
+ $ ${c.cyan}edge-ota login -s https://ota.mycompany.com${c.reset} Sign in to a self-hosted custom domain
691
+
692
+ ${c.bold}CI/CD Environments:${c.reset}
693
+ Instead of interactive login, set the ${c.bold}EDGE_OTA_TOKEN${c.reset} environment variable:
694
+ $ ${c.cyan}export EDGE_OTA_TOKEN="eota_prod_..."${c.reset}
695
+ `).action(async (options) => {
696
+ printBanner();
697
+ const usingCustomServer = options.server && options.server !== DEFAULT_SERVER;
698
+ console.log(` ${c.bold}Sign in to EdgeOTA${c.reset}`);
699
+ if (usingCustomServer) {
700
+ console.log(` ${c.dim}server: ${options.server}${c.reset}`);
701
+ }
702
+ console.log();
703
+ const serverUrl = (options.server || DEFAULT_SERVER).replace(/\/$/, "");
704
+ const email = await ask(` ${c.dim}email:${c.reset} `);
705
+ const password = await askSecret(` ${c.dim}password:${c.reset} `);
706
+ spin.start("authenticating");
707
+ try {
708
+ const res = await fetch(`${serverUrl}/api/auth/login`, {
709
+ method: "POST",
710
+ headers: { "Content-Type": "application/json" },
711
+ body: JSON.stringify({ email, password })
712
+ });
713
+ if (!res.ok) {
714
+ const text = await res.text();
715
+ spin.fail("Authentication failed");
716
+ if (res.status === 401) {
717
+ printErrorBox(
718
+ "Invalid email or password",
719
+ text || "The credentials provided were rejected by the server.",
720
+ [
721
+ `Check your email and password for typos.`,
722
+ `If you don't have an account yet, register at ${c.bold}https://ota.renbo.site${c.reset}`,
723
+ `If using self-hosted server, verify ${c.bold}${serverUrl}${c.reset} is correct.`
724
+ ]
725
+ );
726
+ } else if (res.status === 403) {
727
+ printErrorBox(
728
+ "Account email not verified",
729
+ text || "Your account email has not been verified.",
730
+ `Check your inbox for the verification OTP or log in to the dashboard to verify your email.`
731
+ );
732
+ } else {
733
+ printErrorBox(
734
+ `Server returned HTTP ${res.status}`,
735
+ text || res.statusText,
736
+ `Check server logs or try again shortly.`
737
+ );
738
+ }
739
+ process.exit(1);
599
740
  }
741
+ const data = await res.json();
742
+ saveGlobalConfig({ token: data.token, email: data.email, serverUrl });
743
+ spin.stop();
744
+ const sep = `${c.dim}${"\u2500".repeat(56)}${c.reset}`;
745
+ console.log(sep);
746
+ console.log(` ${c.green}\u2713${c.reset} Logged in as ${c.bold}${data.email}${c.reset}`);
747
+ console.log(` ${c.dim}credentials saved to ${GLOBAL_CONFIG_FILE}${c.reset}`);
748
+ console.log(sep + "\n");
749
+ process.exit(0);
750
+ } catch (e) {
751
+ spin.fail("Connection failed");
752
+ const diag = diagnoseNetworkError(e, serverUrl);
753
+ printErrorBox(diag.title, diag.reason, diag.solution);
754
+ process.exit(1);
755
+ }
756
+ });
757
+ program.command("logout").description("Remove stored credentials and log out").addHelpText("after", `
758
+ ${c.bold}Details:${c.reset}
759
+ Deletes the global configuration file at ${c.dim}${GLOBAL_CONFIG_FILE}${c.reset}.
760
+ Project private signing keys in ${c.dim}${GLOBAL_KEYS_DIR}${c.reset} are preserved.
761
+ `).action(() => {
762
+ printBanner();
763
+ const cfg = loadGlobalConfig();
764
+ clearGlobalConfig();
765
+ if (cfg?.email) {
766
+ console.log(` ${c.green}\u2713${c.reset} Logged out from ${c.bold}${cfg.email}${c.reset}
767
+ `);
768
+ } else {
769
+ console.log(` ${c.dim}Already logged out.${c.reset}
770
+ `);
771
+ }
772
+ process.exit(0);
600
773
  });
601
- // ──────────────────────────────────────────────────────────────────────────────
602
- // edge-ota logout
603
- // ──────────────────────────────────────────────────────────────────────────────
604
- program
605
- .command("logout")
606
- .description("Remove stored credentials")
607
- .action(() => {
608
- printBanner();
609
- const cfg = loadGlobalConfig();
610
- clearGlobalConfig();
611
- if (cfg?.email) {
612
- console.log(` ${c.green}✓${c.reset} Logged out from ${c.bold}${cfg.email}${c.reset}\n`);
774
+ 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", `
775
+ ${c.bold}What this command does:${c.reset}
776
+ 1. Authenticates with your EdgeOTA server
777
+ 2. Creates or links a project ID
778
+ 3. Generates a cryptographic ECDSA P-256 code-signing keypair
779
+ 4. Stores private key securely in ${c.dim}~/.config/edge-ota/keys/<projectId>.key${c.reset}
780
+ 5. Updates ${c.bold}app.json${c.reset} with ${c.dim}expo.updates.url${c.reset} and public key
781
+
782
+ ${c.bold}Critical Next Step:${c.reset}
783
+ After running ${c.cyan}edge-ota init${c.reset}, you ${c.bold}MUST rebuild your native app${c.reset}:
784
+ $ ${c.cyan}npx expo prebuild --clean${c.reset}
785
+ $ ${c.cyan}npx expo run:android${c.reset} (or ${c.cyan}npx expo run:ios${c.reset} / ${c.cyan}eas build${c.reset})
786
+ `).action(async (options) => {
787
+ printBanner();
788
+ const token = resolveToken();
789
+ if (!token) {
790
+ printErrorBox(
791
+ "Not logged in",
792
+ "You must be authenticated to initialize a project.",
793
+ [
794
+ `Run ${c.cyan}edge-ota login${c.reset} to authenticate interactively.`,
795
+ `Or set ${c.cyan}export EDGE_OTA_TOKEN="eota_prod_..."${c.reset} in your environment.`
796
+ ]
797
+ );
798
+ process.exit(1);
799
+ }
800
+ const globalCfg = loadGlobalConfig();
801
+ const serverUrl = (options.server || globalCfg?.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
802
+ const cwd = process.cwd();
803
+ spin.start("fetching existing projects from server");
804
+ let projects = [];
805
+ try {
806
+ const res = await fetch(`${serverUrl}/api/projects`, {
807
+ headers: { Authorization: `Bearer ${token}` }
808
+ });
809
+ if (res.ok) {
810
+ projects = await res.json();
613
811
  }
614
- else {
615
- console.log(` ${c.dim}Already logged out.${c.reset}\n`);
812
+ spin.stop();
813
+ } catch (e) {
814
+ spin.fail("Failed to fetch projects list");
815
+ const diag = diagnoseNetworkError(e, serverUrl);
816
+ printWarningBox(diag.title, diag.reason, "Proceeding with new project registration...");
817
+ }
818
+ let projectId = null;
819
+ let projectName = "";
820
+ if (projects.length > 0) {
821
+ const selectOptions = projects.map((p) => ({
822
+ label: `${p.name} ${c.dim}(id: ${p.id.slice(0, 8)}...)${c.reset}`,
823
+ value: p
824
+ }));
825
+ selectOptions.push({
826
+ label: `${c.green}+ Create a new project...${c.reset}`,
827
+ value: null
828
+ });
829
+ const selected = await selectOption(
830
+ "Select a project to associate with this app:",
831
+ selectOptions
832
+ );
833
+ if (selected) {
834
+ projectId = selected.id;
835
+ projectName = selected.name;
616
836
  }
617
- process.exit(0);
618
- });
619
- // ──────────────────────────────────────────────────────────────────────────────
620
- // edge-ota init
621
- // ──────────────────────────────────────────────────────────────────────────────
622
- program
623
- .command("init")
624
- .description("Register project on EdgeOTA, generate signing keys, configure app.json")
625
- .option("-s, --server <url>", "EdgeOTA server URL (overrides logged-in server)")
626
- .action(async (options) => {
627
- printBanner();
628
- const token = resolveToken();
629
- if (!token) {
630
- console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.\n`);
837
+ }
838
+ const keys = await generateECDSAKeyPair();
839
+ if (projectId) {
840
+ spin.start(`associating project "${projectName}"`);
841
+ try {
842
+ const res = await fetch(`${serverUrl}/api/projects/${projectId}`, {
843
+ method: "PUT",
844
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
845
+ body: JSON.stringify({ name: projectName, publicKey: keys.publicKey })
846
+ });
847
+ if (!res.ok) {
848
+ const text = await res.text();
849
+ spin.fail("Failed to update project public key");
850
+ printErrorBox(
851
+ `Server returned HTTP ${res.status}`,
852
+ text,
853
+ `Ensure your user account has administrator or member permissions for project "${projectName}".`
854
+ );
631
855
  process.exit(1);
856
+ }
857
+ spin.stop(`associated project "${projectName}"`);
858
+ } catch (e) {
859
+ spin.fail("Connection error during project update");
860
+ const diag = diagnoseNetworkError(e, serverUrl);
861
+ printErrorBox(diag.title, diag.reason, diag.solution);
862
+ process.exit(1);
632
863
  }
633
- const globalCfg = loadGlobalConfig();
634
- const serverUrl = (options.server || globalCfg?.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
635
- const cwd = process.cwd();
636
- // Fetch existing projects
637
- spin.start("fetching existing projects from server");
638
- let projects = [];
864
+ } else {
865
+ let suggestedName = path.basename(cwd);
639
866
  try {
640
- const res = await fetch(`${serverUrl}/api/projects`, {
641
- headers: { Authorization: `Bearer ${token}` }
642
- });
643
- if (res.ok) {
644
- projects = await res.json();
645
- }
646
- spin.stop();
647
- }
648
- catch (e) {
649
- spin.fail(`Failed to fetch projects: ${e.message}`);
650
- // Continue with creation if list fails
651
- }
652
- let projectId = null;
653
- let projectName = "";
654
- if (projects.length > 0) {
655
- const options = projects.map(p => ({
656
- label: `${p.name} ${c.dim}(id: ${p.id.slice(0, 8)}...)${c.reset}`,
657
- value: p
658
- }));
659
- options.push({
660
- label: `${c.green}Create a new project...${c.reset}`,
661
- value: null
662
- });
663
- const selected = await selectOption("Select a project to associate with this app:", options);
664
- if (selected) {
665
- projectId = selected.id;
666
- projectName = selected.name;
667
- }
668
- }
669
- const keys = await generateECDSAKeyPair();
670
- if (projectId) {
671
- // Re-initialize existing project: update public key on the server
672
- spin.start(`associating project "${projectName}"`);
673
- try {
674
- const res = await fetch(`${serverUrl}/api/projects/${projectId}`, {
675
- method: "PUT",
676
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
677
- body: JSON.stringify({ name: projectName, publicKey: keys.publicKey }),
678
- });
679
- if (!res.ok) {
680
- const text = await res.text();
681
- spin.fail(`Failed to update project public key on server: ${text}`);
682
- process.exit(1);
683
- }
684
- spin.stop(`associated project "${projectName}"`);
685
- }
686
- catch (e) {
687
- spin.fail(`Connection error: ${e.message}`);
688
- process.exit(1);
689
- }
690
- }
691
- else {
692
- // Create new project
693
- let suggestedName = path.basename(cwd);
694
- try {
695
- const data = JSON.parse(fs.readFileSync(path.resolve(cwd, "app.json"), "utf-8"));
696
- suggestedName = data?.expo?.name || suggestedName;
697
- }
698
- catch { /* ignore */ }
699
- projectName = (await ask(` ${c.dim}project name [${suggestedName}]:${c.reset} `)) || suggestedName;
700
- spin.start("registering project on server");
701
- try {
702
- const res = await fetch(`${serverUrl}/api/projects`, {
703
- method: "POST",
704
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
705
- body: JSON.stringify({ name: projectName, publicKey: keys.publicKey }),
706
- });
707
- if (!res.ok) {
708
- const text = await res.text();
709
- spin.fail(`Failed to register project: ${text}`);
710
- process.exit(1);
711
- }
712
- const data = await res.json();
713
- projectId = data.id;
714
- spin.stop("project registered");
715
- }
716
- catch (e) {
717
- spin.fail(`Connection error: ${e.message}`);
718
- process.exit(1);
719
- }
720
- }
721
- // Save private key globally
722
- if (projectId) {
723
- savePrivateKey(projectId, keys.privateKey);
724
- // Update app.json
725
- updateAppJson(cwd, serverUrl, projectId, keys.publicKey);
726
- const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
727
- console.log(`\n${sep}`);
728
- console.log(` ${c.green}✓${c.reset} ${c.bold}Initialised${c.reset}`);
729
- console.log(sep);
730
- console.log(` project ${c.dim}${projectId}${c.reset}`);
731
- console.log(` server ${c.dim}${serverUrl}${c.reset}`);
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}`);
751
- console.log(sep + "\n");
752
- console.log(` Then run ${c.cyan}edge-ota push${c.reset} to publish your first update.\n`);
753
- process.exit(0);
867
+ const data = JSON.parse(fs.readFileSync(path.resolve(cwd, "app.json"), "utf-8"));
868
+ suggestedName = data?.expo?.name || suggestedName;
869
+ } catch {
754
870
  }
755
- });
756
- // ──────────────────────────────────────────────────────────────────────────────
757
- // edge-ota push
758
- // ──────────────────────────────────────────────────────────────────────────────
759
- program
760
- .command("push")
761
- .description("Export your Expo bundle and publish an OTA update")
762
- .option("-c, --channel <channel>", "Deployment channel", "production")
763
- .option("-p, --platform <platform>", "Target platform: ios | android | all", "all")
764
- .option("--skip-export", "Skip expo export (use existing ./dist directory)")
765
- .option("--dry-run", "Build and sign the payload but do NOT upload")
766
- .action(async (options) => {
767
- const cwd = process.cwd();
768
- const token = resolveToken();
769
- printBanner();
770
- if (!token) {
771
- console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.`);
772
- console.error(` Or set ${c.dim}EDGE_OTA_TOKEN${c.reset} environment variable.\n`);
871
+ projectName = await ask(` ${c.dim}project name [${suggestedName}]:${c.reset} `) || suggestedName;
872
+ spin.start("registering project on server");
873
+ try {
874
+ const res = await fetch(`${serverUrl}/api/projects`, {
875
+ method: "POST",
876
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
877
+ body: JSON.stringify({ name: projectName, publicKey: keys.publicKey })
878
+ });
879
+ if (!res.ok) {
880
+ const text = await res.text();
881
+ spin.fail("Failed to register project");
882
+ printErrorBox(
883
+ `Server returned HTTP ${res.status}`,
884
+ text,
885
+ `Check that your session token is valid and project name is acceptable.`
886
+ );
773
887
  process.exit(1);
888
+ }
889
+ const data = await res.json();
890
+ projectId = data.id;
891
+ spin.stop("project registered");
892
+ } catch (e) {
893
+ spin.fail("Connection error during registration");
894
+ const diag = diagnoseNetworkError(e, serverUrl);
895
+ printErrorBox(diag.title, diag.reason, diag.solution);
896
+ process.exit(1);
774
897
  }
775
- // Auto-detect from app.json
776
- const appCfg = readAppJson(cwd);
777
- const { serverUrl, projectId, runtimeVersion } = appCfg;
778
- // Load private signing key
779
- const privateKey = projectId ? loadPrivateKey(projectId) : null;
780
- if (!privateKey) {
781
- console.error(` ${c.red}✗${c.reset} No signing key found for this project.`);
782
- if (projectId) {
783
- console.error(` Expected at: ${c.dim}${path.join(GLOBAL_KEYS_DIR, `${projectId}.key`)}${c.reset}`);
898
+ }
899
+ if (projectId) {
900
+ savePrivateKey(projectId, keys.privateKey);
901
+ updateAppJson(cwd, serverUrl, projectId, keys.publicKey);
902
+ const sep = `${c.dim}${"\u2500".repeat(56)}${c.reset}`;
903
+ console.log(`
904
+ ${sep}`);
905
+ console.log(` ${c.green}\u2713${c.reset} ${c.bold}Initialised Successfully${c.reset}`);
906
+ console.log(sep);
907
+ console.log(` project ${c.dim}${projectId}${c.reset}`);
908
+ console.log(` server ${c.dim}${serverUrl}${c.reset}`);
909
+ console.log(` key file ${c.dim}${path.join(GLOBAL_KEYS_DIR, `${projectId}.key`)}${c.reset}`);
910
+ console.log(sep);
911
+ console.log(`
912
+ ${c.bold}${c.yellow} \u26A0 REBUILD REQUIRED${c.reset}`);
913
+ console.log(sep);
914
+ console.log(` ${c.bold}Your native app MUST be rebuilt for OTA updates${c.reset}`);
915
+ console.log(` ${c.bold}to work.${c.reset} The server URL is baked into native`);
916
+ console.log(` binary configurations at build time \u2014 not read dynamically from app.json.`);
917
+ console.log();
918
+ console.log(` Run these commands in order:`);
919
+ console.log();
920
+ console.log(` ${c.cyan}npx expo prebuild --clean${c.reset}`);
921
+ console.log(` ${c.cyan}npx expo run:android${c.reset} (or ${c.cyan}npx expo run:ios${c.reset})`);
922
+ console.log();
923
+ console.log(` Or via EAS Build:`);
924
+ console.log(` ${c.cyan}eas build --profile production${c.reset}`);
925
+ console.log();
926
+ console.log(` ${c.red}Skipping this WILL cause "Failed to check for${c.reset}`);
927
+ console.log(` ${c.red}update" errors on app launch.${c.reset}`);
928
+ console.log(sep + "\n");
929
+ console.log(` Then run ${c.cyan}edge-ota push${c.reset} to publish your first update.
930
+ `);
931
+ process.exit(0);
932
+ }
933
+ });
934
+ 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", `
935
+ ${c.bold}Examples:${c.reset}
936
+ $ ${c.cyan}edge-ota push${c.reset} Publish update to 'production' channel
937
+ $ ${c.cyan}edge-ota push -c staging${c.reset} Publish update to 'staging' channel
938
+ $ ${c.cyan}edge-ota push -p android${c.reset} Publish to Android only
939
+ $ ${c.cyan}edge-ota push -r 1.0.5${c.reset} Override runtime version to 1.0.5
940
+ $ ${c.cyan}edge-ota push -r 1.0.0,1.0.1,2.0.0${c.reset} Hotfix multiple native runtime versions simultaneously
941
+ $ ${c.cyan}edge-ota push --skip-export${c.reset} Deploy existing ./dist without re-running expo export
942
+ $ ${c.cyan}edge-ota push --dry-run${c.reset} Verify build, asset collection, and signature locally
943
+
944
+ ${c.bold}How It Works:${c.reset}
945
+ 1. Runs ${c.dim}npx expo export${c.reset} to produce Hermes bytecode bundles (.hbc)
946
+ 2. Discovers and uploads static media assets (.png, .jpg, .svg, .ttf, etc.)
947
+ 3. Signs bundle hash with private key in ${c.dim}~/.config/edge-ota/keys/<projectId>.key${c.reset}
948
+ 4. Publishes update to EdgeOTA server for instant client OTA sync
949
+ `).action(async (options) => {
950
+ const cwd = process.cwd();
951
+ const token = resolveToken();
952
+ printBanner();
953
+ if (!token) {
954
+ printErrorBox(
955
+ "Not logged in",
956
+ "You must be authenticated to publish an OTA update.",
957
+ [
958
+ `Run ${c.cyan}edge-ota login${c.reset} to sign in to your EdgeOTA account.`,
959
+ `Or set ${c.cyan}export EDGE_OTA_TOKEN="eota_prod_..."${c.reset} in your environment (recommended for CI/CD).`
960
+ ]
961
+ );
962
+ process.exit(1);
963
+ }
964
+ const appCfg = readAppJson(cwd);
965
+ const { serverUrl, projectId } = appCfg;
966
+ const runtimeVersion = options.runtime || appCfg.runtimeVersion;
967
+ const privateKey = projectId ? loadPrivateKey(projectId) : null;
968
+ if (!privateKey) {
969
+ const keyPath = projectId ? path.join(GLOBAL_KEYS_DIR, `${projectId}.key`) : "unknown";
970
+ printErrorBox(
971
+ "No signing key found for this project",
972
+ `Expected private key file at: ${keyPath}`,
973
+ [
974
+ `Run ${c.cyan}edge-ota init${c.reset} to re-associate this project and generate a new keypair.`,
975
+ `If collaborating with a team, copy the project's private key into ${c.bold}${keyPath}${c.reset}`,
976
+ `Or generate keys manually using ${c.cyan}edge-ota keygen${c.reset}`
977
+ ]
978
+ );
979
+ process.exit(1);
980
+ }
981
+ const uploadUrl = projectId ? `${serverUrl}/api/projects/${projectId}/updates` : `${serverUrl}/api/updates`;
982
+ const distDir = path.resolve(cwd, "dist");
983
+ if (!options.skipExport) {
984
+ spin.start("running expo export");
985
+ await new Promise((resolve, reject) => {
986
+ const proc = spawn("npx", ["expo", "export"], {
987
+ cwd,
988
+ stdio: ["inherit", "pipe", "pipe"],
989
+ shell: process.platform === "win32"
990
+ });
991
+ proc.stdout?.on("data", (chunk) => {
992
+ const text = chunk.toString();
993
+ for (const line of text.split("\n")) {
994
+ const t = line.trim();
995
+ if (t.startsWith("_expo/static/js/") || t.match(/\.(hbc|js)\s+\(\d/)) {
996
+ spin.stop();
997
+ console.log(` ${c.dim}${t}${c.reset}`);
998
+ spin.start("running expo export");
999
+ }
1000
+ if (t.match(/Bundled \d+ms/)) {
1001
+ spin.update("expo export \u2014 " + t.replace("Bundled", "").trim());
1002
+ }
784
1003
  }
785
- console.error(` Run ${c.cyan}edge-ota init${c.reset} to set up code signing.\n`);
786
- process.exit(1);
787
- }
788
- const uploadUrl = projectId
789
- ? `${serverUrl}/api/projects/${projectId}/updates`
790
- : `${serverUrl}/api/updates`;
791
- const distDir = path.resolve(cwd, "dist");
792
- // ── Step 1: Expo export ──────────────────────────────────────────────────
793
- if (!options.skipExport) {
794
- spin.start("running expo export");
795
- await new Promise((resolve, reject) => {
796
- const proc = spawn("npx", ["expo", "export"], {
797
- cwd,
798
- stdio: ["inherit", "pipe", "pipe"],
799
- shell: process.platform === "win32",
800
- });
801
- // Filter stdout: only show platform bundle lines
802
- proc.stdout?.on("data", (chunk) => {
803
- const text = chunk.toString();
804
- for (const line of text.split("\n")) {
805
- const t = line.trim();
806
- // Show platform bundle summary lines only
807
- if (t.startsWith("_expo/static/js/") || t.match(/\.(hbc|js)\s+\(\d/)) {
808
- spin.stop();
809
- console.log(` ${c.dim}${t}${c.reset}`);
810
- spin.start("running expo export");
811
- }
812
- // Update spinner with bundling progress
813
- if (t.match(/Bundled \d+ms/)) {
814
- spin.update("expo export — " + t.replace("Bundled", "").trim());
815
- }
816
- }
817
- });
818
- // Suppress stderr noise but capture real errors
819
- let stderrBuf = "";
820
- proc.stderr?.on("data", (chunk) => { stderrBuf += chunk.toString(); });
821
- proc.on("close", code => {
822
- if (code === 0) {
823
- spin.stop("expo export complete");
824
- resolve();
825
- }
826
- else {
827
- spin.fail("expo export failed");
828
- // Print last few lines of stderr for context
829
- const lines = stderrBuf.split("\n").filter(Boolean);
830
- for (const l of lines.slice(-10))
831
- console.error(` ${c.dim}${l}${c.reset}`);
832
- reject(new Error(`expo export exited with code ${code}`));
833
- }
834
- });
835
- }).catch(() => process.exit(1));
836
- }
837
- else {
838
- console.log(` ${c.dim}skip-export — using existing ./dist${c.reset}`);
1004
+ });
1005
+ let stderrBuf = "";
1006
+ proc.stderr?.on("data", (chunk) => {
1007
+ stderrBuf += chunk.toString();
1008
+ });
1009
+ proc.on("close", (code) => {
1010
+ if (code === 0) {
1011
+ spin.stop("expo export complete");
1012
+ resolve();
1013
+ } else {
1014
+ spin.fail("expo export failed");
1015
+ printErrorBox(
1016
+ "Expo export failed",
1017
+ `npx expo export exited with code ${code}`,
1018
+ [
1019
+ `Run ${c.bold}npx expo export${c.reset} directly in your terminal to see full Metro compiler logs.`,
1020
+ `Check for TypeScript compiler errors, missing dependencies, or invalid asset imports.`,
1021
+ `Recent stderr output:
1022
+ ${c.dim}${stderrBuf.slice(-400).trim()}${c.reset}`
1023
+ ]
1024
+ );
1025
+ reject(new Error(`expo export exited with code ${code}`));
1026
+ }
1027
+ });
1028
+ }).catch(() => process.exit(1));
1029
+ } else {
1030
+ console.log(` ${c.dim}skip-export \u2014 using existing ./dist${c.reset}`);
1031
+ }
1032
+ if (!fs.existsSync(distDir)) {
1033
+ printErrorBox(
1034
+ "dist directory not found",
1035
+ `Expected export output at ${distDir}`,
1036
+ [
1037
+ `Run ${c.cyan}edge-ota push${c.reset} without ${c.dim}--skip-export${c.reset} to automatically generate bundles.`,
1038
+ `Or manually build using ${c.cyan}npx expo export${c.reset}`
1039
+ ]
1040
+ );
1041
+ process.exit(1);
1042
+ }
1043
+ spin.start("collecting assets");
1044
+ const assets = await collectAssets(distDir);
1045
+ const mediaAssets = assets.filter((a) => !a.key.endsWith(".js") && !a.key.endsWith(".hbc"));
1046
+ spin.stop(`found ${assets.length} total asset(s) (${mediaAssets.length} static assets)`);
1047
+ if (mediaAssets.length > 0 && !options.dryRun) {
1048
+ spin.start(`uploading ${mediaAssets.length} static asset(s)`);
1049
+ try {
1050
+ const assetForm = new FormData();
1051
+ for (const ma of mediaAssets) {
1052
+ const fileBuf = fs.readFileSync(ma.localPath);
1053
+ assetForm.append("assets", new Blob([fileBuf], { type: ma.contentType }), path.basename(ma.localPath));
1054
+ }
1055
+ const assetUploadUrl = `${serverUrl}/api/assets/upload`;
1056
+ const assetRes = await fetch(assetUploadUrl, {
1057
+ method: "POST",
1058
+ headers: {
1059
+ Authorization: `Bearer ${token}`,
1060
+ ...projectId ? { "X-Project-Id": projectId } : {}
1061
+ },
1062
+ body: assetForm
1063
+ });
1064
+ if (assetRes.ok) {
1065
+ spin.stop(`synced ${mediaAssets.length} static asset(s)`);
1066
+ } else {
1067
+ spin.stop(`asset sync notice: server returned HTTP ${assetRes.status}`);
1068
+ }
1069
+ } catch (assetErr) {
1070
+ spin.stop(`asset sync: ${c.dim}${assetErr.message}${c.reset}`);
839
1071
  }
840
- if (!fs.existsSync(distDir)) {
841
- console.error(` ${c.red}✗${c.reset} ./dist not found after export\n`);
842
- process.exit(1);
1072
+ }
1073
+ const platforms = options.platform === "all" ? ["ios", "android"] : [options.platform];
1074
+ const sep = `${c.dim}${"\u2500".repeat(56)}${c.reset}`;
1075
+ for (const platform of platforms) {
1076
+ console.log(`
1077
+ ${sep}`);
1078
+ console.log(` ${c.bold}${platform}${c.reset}`);
1079
+ console.log(sep);
1080
+ const bundlePath = findBundle(distDir, platform);
1081
+ if (!bundlePath) {
1082
+ printWarningBox(
1083
+ `No bundle found for ${platform}`,
1084
+ `Expected bundle at ./dist/_expo/static/js/${platform}/ or ./dist/index.${platform}.hbc`,
1085
+ `If this platform is not supported in your app, ignore this or pass -p <platform>.`
1086
+ );
1087
+ continue;
843
1088
  }
844
- // ── Step 2: Collect assets ───────────────────────────────────────────────
845
- spin.start("collecting assets");
846
- const assets = await collectAssets(distDir);
847
- spin.stop(`found ${assets.length} asset(s)`);
848
- const platforms = options.platform === "all" ? ["ios", "android"] : [options.platform];
849
- const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
850
- // ── Step 3: Per-platform upload ──────────────────────────────────────────
851
- for (const platform of platforms) {
852
- console.log(`\n${sep}`);
853
- console.log(` ${c.bold}${platform}${c.reset}`);
854
- console.log(sep);
855
- const bundlePath = findBundle(distDir, platform);
856
- if (!bundlePath) {
857
- console.warn(` ${c.yellow}⚠${c.reset} no bundle found for ${platform}, skipping`);
858
- continue;
859
- }
860
- const bundleHash = await hashFile(bundlePath);
861
- const bundleSize = (fs.statSync(bundlePath).size / 1024 / 1024).toFixed(2);
862
- console.log(` bundle ${c.dim}${bundleHash.slice(0, 16)}… ${bundleSize} MB${c.reset}`);
863
- const payloadObj = {
864
- channel: options.channel,
865
- runtimeVersion,
866
- platform,
867
- bundleHash,
868
- timestamp: Date.now(),
869
- assetCount: assets.length,
870
- publicKey: appCfg.publicKey,
1089
+ const bundleHash = await hashFile(bundlePath);
1090
+ const bundleSize = (fs.statSync(bundlePath).size / 1024 / 1024).toFixed(2);
1091
+ console.log(` bundle ${c.dim}${bundleHash.slice(0, 16)}\u2026 ${bundleSize} MB${c.reset}`);
1092
+ const payloadObj = {
1093
+ channel: options.channel,
1094
+ runtimeVersion,
1095
+ platform,
1096
+ bundleHash,
1097
+ timestamp: Date.now(),
1098
+ assets: mediaAssets.map((a) => {
1099
+ const ext = a.fileExtension || path.extname(a.localPath) || "";
1100
+ return {
1101
+ hash: a.hash,
1102
+ key: path.basename(a.key, ext),
1103
+ fileExtension: ext,
1104
+ contentType: a.contentType
871
1105
  };
872
- const payloadStr = JSON.stringify(payloadObj);
873
- spin.start("signing");
874
- const signature = await signPayload(payloadStr, privateKey);
875
- spin.stop(`signed ${c.dim}${signature.slice(0, 20)}…${c.reset}`);
876
- if (options.dryRun) {
877
- console.log(` ${c.yellow}dry-run${c.reset} upload skipped`);
878
- continue;
879
- }
880
- spin.start("uploading");
881
- const bundleBuffer = fs.readFileSync(bundlePath);
882
- const form = new FormData();
883
- form.append("bundle", new Blob([bundleBuffer], { type: "application/javascript" }), `bundle-${platform}.hbc`);
884
- form.append("payload", payloadStr);
885
- form.append("signature", signature);
886
- form.append("platform", platform);
887
- const response = await fetch(uploadUrl, {
888
- method: "POST",
889
- headers: {
890
- Authorization: `Bearer ${token}`,
891
- ...(projectId ? { "X-Project-Id": projectId } : {})
892
- },
893
- body: form,
894
- }).catch(e => {
895
- spin.fail(`Upload failed: ${e.message}`);
896
- process.exit(1);
897
- });
898
- if (!response.ok) {
899
- const text = await response.text();
900
- spin.fail(`Upload failed (HTTP ${response.status}): ${text}`);
901
- process.exit(1);
902
- }
903
- const body = await response.json().catch(() => ({}));
904
- spin.stop("uploaded");
905
- console.log(` ${c.green}✓${c.reset} deployed`);
906
- console.log(` id ${c.dim}${body.updateId || "—"}${c.reset}`);
907
- console.log(` channel ${options.channel}`);
908
- console.log(` runtime ${runtimeVersion}`);
1106
+ }),
1107
+ assetCount: mediaAssets.length,
1108
+ publicKey: appCfg.publicKey,
1109
+ extra: {
1110
+ expoClient: appCfg.expoConfig || {}
1111
+ }
1112
+ };
1113
+ const payloadStr = JSON.stringify(payloadObj);
1114
+ spin.start("signing bundle hash");
1115
+ const signature = await signPayload(payloadStr, privateKey);
1116
+ spin.stop(`signed ${c.dim}${signature.slice(0, 20)}\u2026${c.reset}`);
1117
+ if (options.dryRun) {
1118
+ console.log(` ${c.yellow}dry-run${c.reset} upload skipped`);
1119
+ continue;
909
1120
  }
910
- console.log(`\n${sep}`);
911
- console.log(` ${c.green}✓${c.reset} ${c.bold}done${c.reset} ${c.dim}update will be applied on next OTA sync${c.reset}`);
912
- console.log(`${sep}\n`);
913
- process.exit(0);
914
- });
915
- // ──────────────────────────────────────────────────────────────────────────────
916
- // edge-ota status
917
- // ──────────────────────────────────────────────────────────────────────────────
918
- program
919
- .command("status")
920
- .description("List recent deployments for this project")
921
- .option("-n, --limit <n>", "Number of releases to show", "10")
922
- .action(async (options) => {
923
- printBanner();
924
- const token = resolveToken();
925
- if (!token) {
926
- console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.\n`);
927
- process.exit(1);
1121
+ spin.start("uploading release to server");
1122
+ const bundleBuffer = fs.readFileSync(bundlePath);
1123
+ const form = new FormData();
1124
+ form.append("bundle", new Blob([bundleBuffer], { type: "application/javascript" }), `bundle-${platform}.hbc`);
1125
+ form.append("payload", payloadStr);
1126
+ form.append("signature", signature);
1127
+ form.append("platform", platform);
1128
+ let response;
1129
+ try {
1130
+ response = await fetch(uploadUrl, {
1131
+ method: "POST",
1132
+ headers: {
1133
+ Authorization: `Bearer ${token}`,
1134
+ ...projectId ? { "X-Project-Id": projectId } : {}
1135
+ },
1136
+ body: form
1137
+ });
1138
+ } catch (uploadNetErr) {
1139
+ spin.fail("Upload connection failed");
1140
+ const diag = diagnoseNetworkError(uploadNetErr, serverUrl);
1141
+ printErrorBox(diag.title, diag.reason, diag.solution);
1142
+ process.exit(1);
928
1143
  }
929
- const cwd = process.cwd();
930
- const appCfg = readAppJson(cwd);
931
- const { serverUrl, projectId } = appCfg;
932
- const headers = { Authorization: `Bearer ${token}` };
933
- if (projectId)
934
- headers["x-project-id"] = projectId;
935
- spin.start("fetching releases");
936
- const res = await fetch(`${serverUrl}/api/releases`, { headers }).catch(e => {
937
- spin.fail(`Connection failed: ${e.message}`);
938
- process.exit(1);
939
- });
940
- if (!res.ok) {
941
- spin.fail(`Failed to fetch releases (HTTP ${res.status})`);
942
- process.exit(1);
1144
+ if (!response.ok) {
1145
+ const text = await response.text();
1146
+ spin.fail(`Upload failed (HTTP ${response.status})`);
1147
+ if (response.status === 401) {
1148
+ printErrorBox(
1149
+ "Authentication expired",
1150
+ "Your session token is no longer valid.",
1151
+ `Run ${c.cyan}edge-ota login${c.reset} to refresh your credentials.`
1152
+ );
1153
+ } else if (response.status === 403) {
1154
+ printErrorBox(
1155
+ "Permission denied",
1156
+ text || `You do not have permission to publish updates to project ${projectId}.`,
1157
+ `Check your team role on ${c.bold}https://ota.renbo.site${c.reset} or verify API key permissions.`
1158
+ );
1159
+ } else if (response.status === 404) {
1160
+ printErrorBox(
1161
+ "Project not found on server",
1162
+ `Project ID "${projectId}" was not found at ${serverUrl}.`,
1163
+ `Run ${c.cyan}edge-ota init${c.reset} to re-associate with an existing project or create a new one.`
1164
+ );
1165
+ } else if (response.status === 413) {
1166
+ printErrorBox(
1167
+ "Payload too large",
1168
+ "The bundle file exceeds server upload size limits.",
1169
+ "Consider optimizing your bundle, removing large embedded media, or adjusting server body limit."
1170
+ );
1171
+ } else {
1172
+ printErrorBox(`Server returned HTTP ${response.status}`, text, "Check server logs or try again shortly.");
1173
+ }
1174
+ process.exit(1);
943
1175
  }
944
- const releases = await res.json();
945
- spin.stop();
946
- if (!releases.length) {
947
- console.log(` ${c.dim}No releases found.${c.reset}\n`);
948
- process.exit(0);
1176
+ const body = await response.json().catch(() => ({}));
1177
+ spin.stop("uploaded");
1178
+ const runtimesDisplay = body.runtimeVersions ? body.runtimeVersions.join(", ") : runtimeVersion;
1179
+ console.log(` ${c.green}\u2713${c.reset} deployed`);
1180
+ console.log(` id ${c.dim}${body.updateId || "\u2014"}${c.reset}`);
1181
+ console.log(` channel ${options.channel}`);
1182
+ console.log(` runtime(s) ${runtimesDisplay}`);
1183
+ if (mediaAssets.length > 0) {
1184
+ console.log(` assets ${mediaAssets.length} static asset(s) linked`);
949
1185
  }
950
- const limit = parseInt(options.limit);
951
- const rows = releases.slice(0, limit).map((r) => ({
952
- ID: r.id?.slice(0, 8) ?? "—",
953
- Channel: r.channel ?? "—",
954
- Runtime: r.runtime ?? "—",
955
- Platform: r.platform ?? "all",
956
- Created: r.created_at ? new Date(r.created_at).toLocaleString() : "—",
957
- }));
958
- console.table(rows);
959
- process.exit(0);
1186
+ }
1187
+ console.log(`
1188
+ ${sep}`);
1189
+ console.log(` ${c.green}\u2713${c.reset} ${c.bold}done${c.reset} ${c.dim}update will be applied on next OTA sync${c.reset}`);
1190
+ console.log(`${sep}
1191
+ `);
1192
+ process.exit(0);
960
1193
  });
961
- // ──────────────────────────────────────────────────────────────────────────────
962
- // edge-ota keygen
963
- // ──────────────────────────────────────────────────────────────────────────────
964
- program
965
- .command("keygen")
966
- .description("Generate a fresh ECDSA P-256 key pair (prints to stdout, does not write files)")
967
- .action(async () => {
968
- const keys = await generateECDSAKeyPair();
969
- const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
970
- console.log(`\n${sep}`);
971
- console.log(` ${c.bold}private key${c.reset} ${c.dim}keep secret — never commit${c.reset}`);
972
- console.log(sep);
973
- console.log(keys.privateKey);
974
- console.log(`\n${sep}`);
975
- console.log(` ${c.bold}public key${c.reset} ${c.dim}paste into dashboard → Settings → General${c.reset}`);
976
- console.log(sep);
977
- console.log(keys.publicKey);
1194
+ 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", `
1195
+ ${c.bold}Examples:${c.reset}
1196
+ $ ${c.cyan}edge-ota status${c.reset} Show the last 10 releases
1197
+ $ ${c.cyan}edge-ota status -n 25${c.reset} Show the last 25 releases
1198
+ `).action(async (options) => {
1199
+ printBanner();
1200
+ const token = resolveToken();
1201
+ if (!token) {
1202
+ printErrorBox(
1203
+ "Not logged in",
1204
+ "Authentication is required to query release status.",
1205
+ `Run ${c.cyan}edge-ota login${c.reset} first.`
1206
+ );
1207
+ process.exit(1);
1208
+ }
1209
+ const cwd = process.cwd();
1210
+ const appCfg = readAppJson(cwd);
1211
+ const { serverUrl, projectId } = appCfg;
1212
+ const headers = { Authorization: `Bearer ${token}` };
1213
+ if (projectId) headers["x-project-id"] = projectId;
1214
+ spin.start("fetching releases from server");
1215
+ let res;
1216
+ try {
1217
+ res = await fetch(`${serverUrl}/api/releases`, { headers });
1218
+ } catch (e) {
1219
+ spin.fail("Connection failed");
1220
+ const diag = diagnoseNetworkError(e, serverUrl);
1221
+ printErrorBox(diag.title, diag.reason, diag.solution);
1222
+ process.exit(1);
1223
+ }
1224
+ if (!res.ok) {
1225
+ spin.fail(`Failed to fetch releases (HTTP ${res.status})`);
1226
+ printErrorBox(
1227
+ `Server returned HTTP ${res.status}`,
1228
+ res.statusText,
1229
+ `Verify that project "${projectId}" exists and your token has view permissions.`
1230
+ );
1231
+ process.exit(1);
1232
+ }
1233
+ const releases = await res.json();
1234
+ spin.stop();
1235
+ if (!releases.length) {
1236
+ console.log(` ${c.dim}No releases found for project "${projectId}".${c.reset}`);
1237
+ console.log(` Run ${c.cyan}edge-ota push${c.reset} to publish your first OTA release!
1238
+ `);
978
1239
  process.exit(0);
1240
+ }
1241
+ const limit = parseInt(options.limit);
1242
+ const rows = releases.slice(0, limit).map((r) => ({
1243
+ ID: r.id?.slice(0, 8) ?? "\u2014",
1244
+ Channel: r.channel ?? "\u2014",
1245
+ Runtime: r.runtime ?? "\u2014",
1246
+ Platform: r.platform ?? "all",
1247
+ Created: r.created_at ? new Date(r.created_at).toLocaleString() : "\u2014"
1248
+ }));
1249
+ console.table(rows);
1250
+ console.log();
1251
+ process.exit(0);
1252
+ });
1253
+ program.command("keygen").description("Generate a fresh ECDSA P-256 key pair for code signing").addHelpText("after", `
1254
+ ${c.bold}What to do with these keys:${c.reset}
1255
+ 1. ${c.bold}Private Key:${c.reset} Store securely in ${c.dim}~/.config/edge-ota/keys/<projectId>.key${c.reset}
1256
+ or set in your CI/CD secrets for automated pushes.
1257
+ 2. ${c.bold}Public Key:${c.reset} Save in your project settings on the dashboard (${DEFAULT_SERVER})
1258
+ or paste into ${c.dim}app.json${c.reset} under ${c.dim}expo.extra.edgeOtaPublicKey${c.reset}.
1259
+ `).action(async () => {
1260
+ const keys = await generateECDSAKeyPair();
1261
+ const sep = `${c.dim}${"\u2500".repeat(56)}${c.reset}`;
1262
+ console.log(`
1263
+ ${sep}`);
1264
+ console.log(` ${c.bold}PRIVATE KEY${c.reset} ${c.red}(Keep Secret \u2014 Never Commit to Git)${c.reset}`);
1265
+ console.log(sep);
1266
+ console.log(keys.privateKey);
1267
+ console.log(`
1268
+ ${sep}`);
1269
+ console.log(` ${c.bold}PUBLIC KEY${c.reset} ${c.green}(Paste into Dashboard \u2192 Settings \u2192 Code Signing)${c.reset}`);
1270
+ console.log(sep);
1271
+ console.log(keys.publicKey);
1272
+ console.log(`${sep}
1273
+ `);
1274
+ process.exit(0);
979
1275
  });
980
1276
  await checkAndAutoUpdate();
981
1277
  program.parse(process.argv);