@renbostudios/edge-ota 0.4.0 → 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/README.md +313 -119
- package/dist/index.js +1148 -892
- package/package.json +18 -13
- package/dist/core/crypto.js +0 -87
- package/dist/core/index.js +0 -2
- package/dist/core/manifest.js +0 -108
package/dist/index.js
CHANGED
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
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,1230 @@ import os from "os";
|
|
|
19
8
|
import path from "path";
|
|
20
9
|
import readline from "readline";
|
|
21
10
|
import { fileURLToPath } from "url";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
76
|
+
try {
|
|
77
|
+
if (fs.existsSync(GLOBAL_CONFIG_FILE)) {
|
|
78
|
+
return JSON.parse(fs.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
|
|
34
79
|
}
|
|
35
|
-
|
|
36
|
-
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
37
83
|
}
|
|
38
84
|
function saveGlobalConfig(config) {
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
89
|
+
try {
|
|
90
|
+
fs.rmSync(GLOBAL_CONFIG_FILE);
|
|
91
|
+
} catch {
|
|
92
|
+
}
|
|
47
93
|
}
|
|
48
94
|
function loadPrivateKey(projectId) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
]
|
|
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
|
+
]
|
|
146
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
|
-
|
|
149
|
-
let packageVersion = "0.3.0";
|
|
248
|
+
var packageVersion = "0.4.0";
|
|
150
249
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
const DEFAULT_SERVER = "https://api.ota.renbo.site";
|
|
322
|
+
var DEFAULT_SERVER = "https://api.ota.renbo.site";
|
|
232
323
|
function readAppJson(cwd) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 = 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;
|
|
301
368
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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}`;
|
|
308
404
|
}
|
|
309
|
-
|
|
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 };
|
|
310
418
|
}
|
|
311
419
|
function updateAppJson(cwd, serverUrl, projectId, publicKey) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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}`);
|
|
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;
|
|
342
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
|
+
}
|
|
343
441
|
}
|
|
344
|
-
// ─── Readline helper ─────────────────────────────────────────────────────────
|
|
345
442
|
function ask(query) {
|
|
346
|
-
|
|
347
|
-
|
|
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
|
+
}));
|
|
348
448
|
}
|
|
349
449
|
function askSecret(query) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
else {
|
|
374
|
-
input += ch;
|
|
375
|
-
process.stdout.write("•");
|
|
376
|
-
}
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
else {
|
|
380
|
-
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");
|
|
381
473
|
}
|
|
382
|
-
|
|
474
|
+
});
|
|
475
|
+
} else {
|
|
476
|
+
ask(query).then(resolve);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
383
479
|
}
|
|
384
480
|
function selectOption(question, options) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
resolve(options[0].value);
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
|
-
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);
|
|
401
493
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
});
|
|
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");
|
|
421
506
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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;
|
|
426
532
|
render();
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
});
|
|
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
|
+
});
|
|
452
546
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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"
|
|
468
562
|
};
|
|
469
563
|
async function hashFile(filePath) {
|
|
470
|
-
|
|
471
|
-
|
|
564
|
+
const buffer = fs.readFileSync(filePath);
|
|
565
|
+
return sha256Hex(buffer.buffer.slice(
|
|
566
|
+
buffer.byteOffset,
|
|
567
|
+
buffer.byteOffset + buffer.byteLength
|
|
568
|
+
));
|
|
472
569
|
}
|
|
473
570
|
async function collectAssets(distDir) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
}
|
|
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
|
+
}
|
|
491
586
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
587
|
+
}
|
|
588
|
+
walk(distDir);
|
|
589
|
+
await Promise.all(entries.map(async (e) => {
|
|
590
|
+
e.hash = await hashFile(e.localPath);
|
|
591
|
+
}));
|
|
592
|
+
return entries;
|
|
495
593
|
}
|
|
496
594
|
function findBundle(distDir, platform) {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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;
|
|
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;
|
|
513
607
|
}
|
|
514
|
-
// ─── Auth token resolution ────────────────────────────────────────────────────
|
|
515
608
|
function resolveToken() {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
return cfg.token;
|
|
520
|
-
// 2. Env var fallback
|
|
521
|
-
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;
|
|
522
612
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
program
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
+
`);
|
|
530
627
|
program.action(() => {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
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();
|
|
555
651
|
});
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
console.log();
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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);
|
|
599
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
|
+
}
|
|
722
|
+
});
|
|
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);
|
|
600
739
|
});
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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();
|
|
613
777
|
}
|
|
614
|
-
|
|
615
|
-
|
|
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;
|
|
616
802
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
+
);
|
|
631
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);
|
|
632
829
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const cwd = process.cwd();
|
|
636
|
-
// Fetch existing projects
|
|
637
|
-
spin.start("fetching existing projects from server");
|
|
638
|
-
let projects = [];
|
|
830
|
+
} else {
|
|
831
|
+
let suggestedName = path.basename(cwd);
|
|
639
832
|
try {
|
|
640
|
-
|
|
641
|
-
|
|
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
|
|
833
|
+
const data = JSON.parse(fs.readFileSync(path.resolve(cwd, "app.json"), "utf-8"));
|
|
834
|
+
suggestedName = data?.expo?.name || suggestedName;
|
|
835
|
+
} catch {
|
|
651
836
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
})
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
}
|
|
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);
|
|
690
863
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
+
}
|
|
715
969
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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}`));
|
|
719
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}`);
|
|
720
1037
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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);
|
|
754
|
-
}
|
|
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`);
|
|
773
|
-
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;
|
|
774
1054
|
}
|
|
775
|
-
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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;
|
|
787
1080
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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));
|
|
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);
|
|
836
1103
|
}
|
|
837
|
-
|
|
838
|
-
|
|
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);
|
|
839
1135
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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`);
|
|
843
1145
|
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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,
|
|
871
|
-
};
|
|
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}`);
|
|
909
|
-
}
|
|
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);
|
|
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);
|
|
914
1153
|
});
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
})
|
|
958
|
-
console.
|
|
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
|
+
`);
|
|
959
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);
|
|
960
1212
|
});
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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);
|
|
979
1235
|
});
|
|
980
1236
|
await checkAndAutoUpdate();
|
|
981
1237
|
program.parse(process.argv);
|