@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/README.md +197 -199
- package/dist/index.js +1186 -890
- package/package.json +20 -15
- 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,1270 @@ 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
|
-
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
|
-
|
|
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
|
-
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
program
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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();
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
615
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
|
945
|
-
spin.stop();
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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);
|