@renbostudios/edge-ota 0.2.7 → 0.2.9
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 +12 -111
- package/dist/core/manifest.js +38 -30
- package/dist/index.js +653 -249
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,150 +1,357 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* edge-ota CLI
|
|
3
|
+
* edge-ota CLI — Zero-SDK, self-hostable OTA updates for Expo
|
|
4
|
+
* by Renbo Studios — Mobile App Development Studio
|
|
5
|
+
* https://renbostudios.com
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* The server verifies the ECDSA signature before accepting the bundle,
|
|
14
|
-
* then stores assets on disk (server-node) or R2 (worker-oss) and
|
|
15
|
-
* records the update in the database.
|
|
16
|
-
*
|
|
17
|
-
* From that point the Expo Updates SDK handshake is:
|
|
18
|
-
* GET /api/updates
|
|
19
|
-
* expo-platform: ios | android
|
|
20
|
-
* expo-runtime-version: <semver>
|
|
21
|
-
* expo-channel-name: production | staging | …
|
|
22
|
-
*
|
|
23
|
-
* The server responds with an Expo Updates v1 manifest JSON (signed if a
|
|
24
|
-
* public key is configured) and the SDK fetches launchAsset.url to swap
|
|
25
|
-
* the active bundle.
|
|
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)
|
|
26
14
|
*/
|
|
27
15
|
import { Command } from "commander";
|
|
28
|
-
import {
|
|
16
|
+
import { spawn } from "child_process";
|
|
29
17
|
import fs from "fs";
|
|
18
|
+
import os from "os";
|
|
30
19
|
import path from "path";
|
|
31
|
-
import { createWriteStream } from "fs";
|
|
32
|
-
import archiver from "archiver";
|
|
33
20
|
import readline from "readline";
|
|
21
|
+
import { fileURLToPath } from "url";
|
|
34
22
|
import { generateECDSAKeyPair, signPayload, sha256Hex } from "./core/index.js";
|
|
35
|
-
|
|
36
|
-
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const __dirname = path.dirname(__filename);
|
|
25
|
+
// ─── Global Config Paths ─────────────────────────────────────────────────────
|
|
26
|
+
const GLOBAL_CONFIG_DIR = path.join(os.homedir(), ".config", "edge-ota");
|
|
27
|
+
const GLOBAL_CONFIG_FILE = path.join(GLOBAL_CONFIG_DIR, "config.json");
|
|
28
|
+
const GLOBAL_KEYS_DIR = path.join(GLOBAL_CONFIG_DIR, "keys");
|
|
29
|
+
function loadGlobalConfig() {
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(GLOBAL_CONFIG_FILE)) {
|
|
32
|
+
return JSON.parse(fs.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { /* ignore */ }
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function saveGlobalConfig(config) {
|
|
39
|
+
fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
|
|
40
|
+
fs.writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
function clearGlobalConfig() {
|
|
43
|
+
try {
|
|
44
|
+
fs.rmSync(GLOBAL_CONFIG_FILE);
|
|
45
|
+
}
|
|
46
|
+
catch { /* ignore */ }
|
|
47
|
+
}
|
|
48
|
+
function loadPrivateKey(projectId) {
|
|
49
|
+
// 1. Global key store (new location)
|
|
50
|
+
const globalKeyPath = path.join(GLOBAL_KEYS_DIR, `${projectId}.key`);
|
|
51
|
+
if (fs.existsSync(globalKeyPath)) {
|
|
52
|
+
return fs.readFileSync(globalKeyPath, "utf-8").trim();
|
|
53
|
+
}
|
|
54
|
+
// 2. Legacy local file (backward compat with deprecation notice)
|
|
55
|
+
const localKeyPath = path.resolve(process.cwd(), ".edge-ota.private.key");
|
|
56
|
+
if (fs.existsSync(localKeyPath)) {
|
|
57
|
+
spin.stop();
|
|
58
|
+
console.log(` ${c.yellow}⚠${c.reset} Using legacy ${c.dim}.edge-ota.private.key${c.reset} in project directory.`);
|
|
59
|
+
console.log(` Move it to ${c.dim}${globalKeyPath}${c.reset} and delete the local copy.`);
|
|
60
|
+
return fs.readFileSync(localKeyPath, "utf-8").trim();
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
function savePrivateKey(projectId, key) {
|
|
65
|
+
fs.mkdirSync(GLOBAL_KEYS_DIR, { recursive: true });
|
|
66
|
+
const keyPath = path.join(GLOBAL_KEYS_DIR, `${projectId}.key`);
|
|
67
|
+
fs.writeFileSync(keyPath, key, { mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
// ─── Auto-load .env ───────────────────────────────────────────────────────────
|
|
37
70
|
try {
|
|
38
71
|
const envPath = path.resolve(process.cwd(), ".env");
|
|
39
72
|
if (fs.existsSync(envPath)) {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (!trimmed || trimmed.startsWith("#"))
|
|
73
|
+
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
|
|
74
|
+
const t = line.trim();
|
|
75
|
+
if (!t || t.startsWith("#"))
|
|
44
76
|
continue;
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
77
|
+
const eq = t.indexOf("=");
|
|
78
|
+
if (eq === -1)
|
|
47
79
|
continue;
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
if (!(
|
|
51
|
-
process.env[
|
|
80
|
+
const k = t.slice(0, eq).trim();
|
|
81
|
+
const v = t.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
|
|
82
|
+
if (!(k in process.env))
|
|
83
|
+
process.env[k] = v;
|
|
52
84
|
}
|
|
53
85
|
}
|
|
54
86
|
}
|
|
55
|
-
catch { /* silently skip
|
|
56
|
-
// ─── ANSI
|
|
57
|
-
const
|
|
87
|
+
catch { /* silently skip */ }
|
|
88
|
+
// ─── ANSI Colors ─────────────────────────────────────────────────────────────
|
|
89
|
+
const c = {
|
|
58
90
|
reset: "\x1b[0m",
|
|
59
91
|
bold: "\x1b[1m",
|
|
60
92
|
dim: "\x1b[2m",
|
|
61
93
|
underline: "\x1b[4m",
|
|
62
|
-
// Foreground Colors
|
|
63
|
-
black: "\x1b[30m",
|
|
64
94
|
red: "\x1b[31m",
|
|
65
95
|
green: "\x1b[32m",
|
|
66
96
|
yellow: "\x1b[33m",
|
|
67
97
|
blue: "\x1b[34m",
|
|
68
|
-
magenta: "\x1b[35m",
|
|
69
98
|
cyan: "\x1b[36m",
|
|
70
99
|
white: "\x1b[37m",
|
|
71
100
|
gray: "\x1b[90m",
|
|
72
101
|
};
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
102
|
+
// ─── Spinner ─────────────────────────────────────────────────────────────────
|
|
103
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
104
|
+
const spin = (() => {
|
|
105
|
+
let timer = null;
|
|
106
|
+
let frame = 0;
|
|
107
|
+
let label = "";
|
|
108
|
+
return {
|
|
109
|
+
start(text) {
|
|
110
|
+
label = text;
|
|
111
|
+
frame = 0;
|
|
112
|
+
if (!process.stdout.isTTY) {
|
|
113
|
+
process.stdout.write(` ${text}...\n`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
timer = setInterval(() => {
|
|
117
|
+
const f = `${c.cyan}${FRAMES[frame % FRAMES.length]}${c.reset}`;
|
|
118
|
+
process.stdout.write(`\r ${f} ${c.dim}${label}${c.reset} `);
|
|
119
|
+
frame++;
|
|
120
|
+
}, 80);
|
|
121
|
+
},
|
|
122
|
+
update(text) {
|
|
123
|
+
label = text;
|
|
124
|
+
},
|
|
125
|
+
stop(successMsg) {
|
|
126
|
+
if (timer) {
|
|
127
|
+
clearInterval(timer);
|
|
128
|
+
timer = null;
|
|
129
|
+
}
|
|
130
|
+
if (process.stdout.isTTY) {
|
|
131
|
+
process.stdout.write("\r\x1b[K"); // clear line
|
|
132
|
+
}
|
|
133
|
+
if (successMsg) {
|
|
134
|
+
console.log(` ${c.green}✓${c.reset} ${successMsg}`);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
fail(errMsg) {
|
|
138
|
+
if (timer) {
|
|
139
|
+
clearInterval(timer);
|
|
140
|
+
timer = null;
|
|
141
|
+
}
|
|
142
|
+
if (process.stdout.isTTY)
|
|
143
|
+
process.stdout.write("\r\x1b[K");
|
|
144
|
+
console.error(` ${c.red}✗${c.reset} ${errMsg}`);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
})();
|
|
148
|
+
// ─── Package version ─────────────────────────────────────────────────────────
|
|
149
|
+
let packageVersion = "0.2.9";
|
|
150
|
+
try {
|
|
151
|
+
const pkgPath = path.join(__dirname, "..", "package.json");
|
|
152
|
+
if (fs.existsSync(pkgPath)) {
|
|
153
|
+
packageVersion = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
|
|
154
|
+
}
|
|
79
155
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
});
|
|
90
|
-
});
|
|
156
|
+
catch { /* fallback */ }
|
|
157
|
+
// ─── Banner ───────────────────────────────────────────────────────────────────
|
|
158
|
+
function printBanner() {
|
|
159
|
+
const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
|
|
160
|
+
console.log(`\n${sep}`);
|
|
161
|
+
console.log(` ${c.bold}${c.white}edge-ota${c.reset} ${c.dim}v${packageVersion} · Zero-SDK OTA for Expo${c.reset}`);
|
|
162
|
+
console.log(` ${c.dim}by ${c.reset}${c.bold}Renbo Studios${c.reset}${c.dim} — Mobile App Development Studio${c.reset}`);
|
|
163
|
+
console.log(` ${c.dim}renbostudios.com${c.reset}`);
|
|
164
|
+
console.log(`${sep}\n`);
|
|
91
165
|
}
|
|
92
|
-
function
|
|
166
|
+
function readAppJson(cwd) {
|
|
93
167
|
const appJsonPath = path.resolve(cwd, "app.json");
|
|
94
168
|
if (!fs.existsSync(appJsonPath)) {
|
|
95
|
-
console.
|
|
96
|
-
|
|
169
|
+
console.error(`\n ${c.red}✗${c.reset} ${c.bold}app.json not found${c.reset} in ${cwd}`);
|
|
170
|
+
console.error(` Run this command from your Expo project root.\n`);
|
|
171
|
+
process.exit(1);
|
|
97
172
|
}
|
|
173
|
+
let data;
|
|
98
174
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
175
|
+
data = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
console.error(` ${c.red}✗${c.reset} Failed to parse app.json`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
const expo = data?.expo ?? {};
|
|
182
|
+
// ── Runtime version ──
|
|
183
|
+
const runtimeVersion = expo.runtimeVersion;
|
|
184
|
+
if (!runtimeVersion) {
|
|
185
|
+
console.error(` ${c.red}✗${c.reset} ${c.bold}expo.runtimeVersion${c.reset} is not set in app.json`);
|
|
186
|
+
console.error(` Add: ${c.dim}"runtimeVersion": "1.0.0"${c.reset} under the expo key.\n`);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
// ── Updates URL ──
|
|
190
|
+
const updatesUrl = expo?.updates?.url ?? "";
|
|
191
|
+
if (!updatesUrl) {
|
|
192
|
+
console.error(` ${c.red}✗${c.reset} ${c.bold}expo.updates.url${c.reset} is not configured in app.json`);
|
|
193
|
+
console.error(` Run ${c.cyan}edge-ota init${c.reset} to set this up.\n`);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
// Parse serverUrl and projectId from the updates URL
|
|
197
|
+
// Expected format: https://<host>/api/projects/<uuid>/updates
|
|
198
|
+
// or: https://<host>/api/updates
|
|
199
|
+
let serverUrl;
|
|
200
|
+
let projectId = null;
|
|
201
|
+
try {
|
|
202
|
+
const u = new URL(updatesUrl);
|
|
203
|
+
const projectMatch = u.pathname.match(/\/api\/projects\/([^/]+)\/updates/);
|
|
204
|
+
if (projectMatch) {
|
|
205
|
+
projectId = projectMatch[1];
|
|
206
|
+
serverUrl = `${u.protocol}//${u.host}`;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
serverUrl = `${u.protocol}//${u.host}`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
console.error(` ${c.red}✗${c.reset} Could not parse ${c.dim}expo.updates.url${c.reset} in app.json`);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
return { serverUrl, projectId, runtimeVersion, publicKey: expo.updates?.publicKey };
|
|
217
|
+
}
|
|
218
|
+
function updateAppJson(cwd, serverUrl, projectId, publicKey) {
|
|
219
|
+
const appJsonPath = path.resolve(cwd, "app.json");
|
|
220
|
+
if (!fs.existsSync(appJsonPath))
|
|
221
|
+
return;
|
|
222
|
+
try {
|
|
223
|
+
const data = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
|
|
101
224
|
if (!data.expo)
|
|
102
225
|
data.expo = {};
|
|
103
226
|
if (!data.expo.updates)
|
|
104
227
|
data.expo.updates = {};
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
228
|
+
const cleanUrl = serverUrl.replace(/\/$/, "");
|
|
229
|
+
data.expo.updates.url = projectId
|
|
230
|
+
? `${cleanUrl}/api/projects/${projectId}/updates`
|
|
231
|
+
: `${cleanUrl}/api/updates`;
|
|
232
|
+
data.expo.updates.checkAutomatically = data.expo.updates.checkAutomatically ?? "ON_LOAD";
|
|
233
|
+
data.expo.updates.fallbackToCacheTimeout = data.expo.updates.fallbackToCacheTimeout ?? 30000;
|
|
234
|
+
data.expo.updates.requestHeaders = data.expo.updates.requestHeaders ?? { "expo-channel-name": "production" };
|
|
235
|
+
if (publicKey) {
|
|
236
|
+
data.expo.updates.codeSigningCertificate = undefined; // clear legacy
|
|
237
|
+
data.expo.extra = data.expo.extra ?? {};
|
|
238
|
+
data.expo.extra.edgeOtaPublicKey = publicKey;
|
|
239
|
+
}
|
|
240
|
+
fs.writeFileSync(appJsonPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
241
|
+
console.log(` ${c.dim}app.json${c.reset} updated ${c.dim}expo.updates.url${c.reset}`);
|
|
114
242
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const configPath = path.resolve(cwd, "edge-ota.config.json");
|
|
118
|
-
if (!fs.existsSync(configPath)) {
|
|
119
|
-
console.error("❌ edge-ota.config.json not found. Run `edge-ota init` first.");
|
|
120
|
-
process.exit(1);
|
|
243
|
+
catch (e) {
|
|
244
|
+
console.error(` ${c.yellow}⚠${c.reset} Could not update app.json: ${e.message}`);
|
|
121
245
|
}
|
|
122
|
-
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
123
246
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
process.exit(1);
|
|
129
|
-
}
|
|
130
|
-
return fs.readFileSync(keyPath, "utf-8");
|
|
247
|
+
// ─── Readline helper ─────────────────────────────────────────────────────────
|
|
248
|
+
function ask(query) {
|
|
249
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
250
|
+
return new Promise(resolve => rl.question(query, a => { rl.close(); resolve(a.trim()); }));
|
|
131
251
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
252
|
+
function askSecret(query) {
|
|
253
|
+
return new Promise(resolve => {
|
|
254
|
+
if (process.stdin.isTTY) {
|
|
255
|
+
process.stdout.write(query);
|
|
256
|
+
let input = "";
|
|
257
|
+
process.stdin.setRawMode(true);
|
|
258
|
+
process.stdin.resume();
|
|
259
|
+
process.stdin.setEncoding("utf8");
|
|
260
|
+
process.stdin.on("data", function onData(ch) {
|
|
261
|
+
if (ch === "\r" || ch === "\n") {
|
|
262
|
+
process.stdin.setRawMode(false);
|
|
263
|
+
process.stdin.removeListener("data", onData);
|
|
264
|
+
process.stdout.write("\n");
|
|
265
|
+
resolve(input);
|
|
266
|
+
}
|
|
267
|
+
else if (ch === "\u0003") {
|
|
268
|
+
process.exit(0);
|
|
269
|
+
}
|
|
270
|
+
else if (ch === "\u007f") {
|
|
271
|
+
if (input.length > 0) {
|
|
272
|
+
input = input.slice(0, -1);
|
|
273
|
+
process.stdout.write("\b \b");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
input += ch;
|
|
278
|
+
process.stdout.write("•");
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
ask(query).then(resolve);
|
|
284
|
+
}
|
|
142
285
|
});
|
|
143
286
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
287
|
+
function selectOption(question, options) {
|
|
288
|
+
return new Promise((resolve) => {
|
|
289
|
+
if (!process.stdin.isTTY) {
|
|
290
|
+
console.log(` ${c.bold}${question}${c.reset}`);
|
|
291
|
+
options.forEach((opt, idx) => {
|
|
292
|
+
console.log(` [${idx + 1}] ${opt.label}`);
|
|
293
|
+
});
|
|
294
|
+
ask(` selection (1-${options.length}): `).then((choice) => {
|
|
295
|
+
const idx = parseInt(choice) - 1;
|
|
296
|
+
if (!isNaN(idx) && idx >= 0 && idx < options.length) {
|
|
297
|
+
resolve(options[idx].value);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
resolve(options[0].value);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
let cursor = 0;
|
|
306
|
+
const hideCursor = "\u001b[?25l";
|
|
307
|
+
const showCursor = "\u001b[?25h";
|
|
308
|
+
let hasRendered = false;
|
|
309
|
+
console.log(` ${c.bold}${question}${c.reset}`);
|
|
310
|
+
function render() {
|
|
311
|
+
if (hasRendered) {
|
|
312
|
+
for (let i = 0; i < options.length; i++) {
|
|
313
|
+
process.stdout.write("\r\u001b[K\u001b[A");
|
|
314
|
+
}
|
|
315
|
+
process.stdout.write("\r\u001b[K");
|
|
316
|
+
}
|
|
317
|
+
hasRendered = true;
|
|
318
|
+
options.forEach((opt, idx) => {
|
|
319
|
+
const isSelected = idx === cursor;
|
|
320
|
+
const marker = isSelected ? `${c.cyan}❯${c.reset}` : " ";
|
|
321
|
+
const text = isSelected ? `${c.bold}${c.cyan}${opt.label}${c.reset}` : `${c.dim}${opt.label}${c.reset}`;
|
|
322
|
+
process.stdout.write(` ${marker} ${text}\n`);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
readline.emitKeypressEvents(process.stdin);
|
|
326
|
+
process.stdin.setRawMode(true);
|
|
327
|
+
process.stdin.resume();
|
|
328
|
+
process.stdout.write(hideCursor);
|
|
329
|
+
render();
|
|
330
|
+
function onKeypress(str, key) {
|
|
331
|
+
if (key.ctrl && key.name === "c") {
|
|
332
|
+
process.stdout.write(showCursor);
|
|
333
|
+
process.stdin.setRawMode(false);
|
|
334
|
+
process.stdin.pause();
|
|
335
|
+
process.exit(0);
|
|
336
|
+
}
|
|
337
|
+
if (key.name === "up" || key.name === "k") {
|
|
338
|
+
cursor = (cursor - 1 + options.length) % options.length;
|
|
339
|
+
render();
|
|
340
|
+
}
|
|
341
|
+
else if (key.name === "down" || key.name === "j") {
|
|
342
|
+
cursor = (cursor + 1) % options.length;
|
|
343
|
+
render();
|
|
344
|
+
}
|
|
345
|
+
else if (key.name === "return" || key.name === "enter") {
|
|
346
|
+
process.stdout.write(showCursor);
|
|
347
|
+
process.stdin.setRawMode(false);
|
|
348
|
+
process.stdin.removeListener("keypress", onKeypress);
|
|
349
|
+
process.stdin.pause();
|
|
350
|
+
resolve(options[cursor].value);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
process.stdin.on("keypress", onKeypress);
|
|
354
|
+
});
|
|
148
355
|
}
|
|
149
356
|
const CONTENT_TYPES = {
|
|
150
357
|
".js": "application/javascript",
|
|
@@ -160,290 +367,487 @@ const CONTENT_TYPES = {
|
|
|
160
367
|
".woff": "font/woff",
|
|
161
368
|
".woff2": "font/woff2",
|
|
162
369
|
".mp4": "video/mp4",
|
|
163
|
-
".webm": "video/webm"
|
|
370
|
+
".webm": "video/webm",
|
|
164
371
|
};
|
|
372
|
+
async function hashFile(filePath) {
|
|
373
|
+
const buffer = fs.readFileSync(filePath);
|
|
374
|
+
return sha256Hex(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
|
|
375
|
+
}
|
|
165
376
|
async function collectAssets(distDir) {
|
|
166
377
|
const entries = [];
|
|
167
378
|
function walk(dir) {
|
|
168
379
|
for (const name of fs.readdirSync(dir)) {
|
|
169
380
|
const full = path.join(dir, name);
|
|
170
|
-
|
|
171
|
-
if (stat.isDirectory()) {
|
|
381
|
+
if (fs.statSync(full).isDirectory()) {
|
|
172
382
|
walk(full);
|
|
173
383
|
}
|
|
174
384
|
else {
|
|
175
385
|
const ext = path.extname(name).toLowerCase();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
386
|
+
entries.push({
|
|
387
|
+
localPath: full,
|
|
388
|
+
key: path.relative(distDir, full).replace(/\\/g, "/"),
|
|
389
|
+
contentType: CONTENT_TYPES[ext] || "application/octet-stream",
|
|
390
|
+
hash: ""
|
|
391
|
+
});
|
|
179
392
|
}
|
|
180
393
|
}
|
|
181
394
|
}
|
|
182
395
|
walk(distDir);
|
|
183
|
-
|
|
184
|
-
await Promise.all(entries.map(async (e) => {
|
|
185
|
-
e.hash = await hashFile(e.localPath);
|
|
186
|
-
}));
|
|
396
|
+
await Promise.all(entries.map(async (e) => { e.hash = await hashFile(e.localPath); }));
|
|
187
397
|
return entries;
|
|
188
398
|
}
|
|
189
|
-
// ─── Detect the JS bundle within the Expo export output ──────────────────────
|
|
190
399
|
function findBundle(distDir, platform) {
|
|
191
|
-
// Expo SDK 50+ writes: dist/_expo/static/js/<platform>/<hash>.js (or .hbc)
|
|
192
400
|
const expoStaticJs = path.join(distDir, "_expo", "static", "js", platform);
|
|
193
401
|
if (fs.existsSync(expoStaticJs)) {
|
|
194
402
|
const files = fs.readdirSync(expoStaticJs).filter(f => f.endsWith(".js") || f.endsWith(".hbc"));
|
|
195
403
|
if (files.length)
|
|
196
404
|
return path.join(expoStaticJs, files[0]);
|
|
197
405
|
}
|
|
198
|
-
// Fallback: older SDK flat layout
|
|
199
406
|
const flatJs = path.join(distDir, `index.${platform}.js`);
|
|
200
407
|
if (fs.existsSync(flatJs))
|
|
201
408
|
return flatJs;
|
|
202
409
|
const flatHbc = path.join(distDir, `index.${platform}.hbc`);
|
|
203
410
|
if (fs.existsSync(flatHbc))
|
|
204
411
|
return flatHbc;
|
|
205
|
-
// Any .js or .hbc at root
|
|
206
412
|
const rootJs = fs.readdirSync(distDir).find(f => f.endsWith(".js") || f.endsWith(".hbc"));
|
|
207
413
|
if (rootJs)
|
|
208
414
|
return path.join(distDir, rootJs);
|
|
209
415
|
return null;
|
|
210
416
|
}
|
|
211
|
-
// ───
|
|
417
|
+
// ─── Auth token resolution ────────────────────────────────────────────────────
|
|
418
|
+
function resolveToken() {
|
|
419
|
+
// 1. Global config
|
|
420
|
+
const cfg = loadGlobalConfig();
|
|
421
|
+
if (cfg?.token)
|
|
422
|
+
return cfg.token;
|
|
423
|
+
// 2. Env var fallback
|
|
424
|
+
return process.env.EDGE_OTA_TOKEN || null;
|
|
425
|
+
}
|
|
426
|
+
// ─── Program ─────────────────────────────────────────────────────────────────
|
|
212
427
|
const program = new Command();
|
|
213
428
|
program
|
|
214
429
|
.name("edge-ota")
|
|
215
|
-
.description("Zero-SDK
|
|
216
|
-
.version(
|
|
217
|
-
//
|
|
430
|
+
.description("Zero-SDK OTA update platform for Expo — by Renbo Studios")
|
|
431
|
+
.version(packageVersion);
|
|
432
|
+
// Show help + auth status when called with no arguments
|
|
433
|
+
program.action(() => {
|
|
434
|
+
printBanner();
|
|
435
|
+
const cfg = loadGlobalConfig();
|
|
436
|
+
if (cfg?.email) {
|
|
437
|
+
console.log(` ${c.green}●${c.reset} Logged in as ${c.bold}${cfg.email}${c.reset} ${c.dim}(${cfg.serverUrl})${c.reset}`);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
console.log(` ${c.dim}○ Not logged in — run ${c.reset}${c.cyan}edge-ota login${c.reset}`);
|
|
441
|
+
}
|
|
442
|
+
console.log();
|
|
443
|
+
console.log(` ${c.bold}Commands${c.reset}`);
|
|
444
|
+
const cmds = [
|
|
445
|
+
["login", "Authenticate and save credentials globally"],
|
|
446
|
+
["logout", "Remove stored credentials"],
|
|
447
|
+
["init", "Register project, generate signing keys, configure app.json"],
|
|
448
|
+
["push", "Export bundle and publish an OTA update"],
|
|
449
|
+
["status", "List recent deployments for this project"],
|
|
450
|
+
["keygen", "Generate a new ECDSA key pair (prints to stdout)"],
|
|
451
|
+
];
|
|
452
|
+
for (const [cmd, desc] of cmds) {
|
|
453
|
+
console.log(` ${c.cyan}${cmd.padEnd(10)}${c.reset} ${c.dim}${desc}${c.reset}`);
|
|
454
|
+
}
|
|
455
|
+
console.log();
|
|
456
|
+
console.log(` Run ${c.cyan}edge-ota <command> --help${c.reset} for command-specific options.`);
|
|
457
|
+
console.log();
|
|
458
|
+
});
|
|
459
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
460
|
+
// edge-ota login
|
|
461
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
462
|
+
program
|
|
463
|
+
.command("login")
|
|
464
|
+
.description("Authenticate with your EdgeOTA account")
|
|
465
|
+
.option("-s, --server <url>", "EdgeOTA server URL", "https://api.edge-ota.renbo.site")
|
|
466
|
+
.action(async (options) => {
|
|
467
|
+
printBanner();
|
|
468
|
+
console.log(` ${c.bold}Sign in to EdgeOTA${c.reset}\n`);
|
|
469
|
+
const serverUrl = options.server.replace(/\/$/, "");
|
|
470
|
+
const email = await ask(` ${c.dim}email:${c.reset} `);
|
|
471
|
+
const password = await askSecret(` ${c.dim}password:${c.reset} `);
|
|
472
|
+
spin.start("authenticating");
|
|
473
|
+
try {
|
|
474
|
+
const res = await fetch(`${serverUrl}/api/auth/login`, {
|
|
475
|
+
method: "POST",
|
|
476
|
+
headers: { "Content-Type": "application/json" },
|
|
477
|
+
body: JSON.stringify({ email, password }),
|
|
478
|
+
});
|
|
479
|
+
if (!res.ok) {
|
|
480
|
+
const text = await res.text();
|
|
481
|
+
spin.fail(`Login failed: ${text || res.statusText}`);
|
|
482
|
+
process.exit(1);
|
|
483
|
+
}
|
|
484
|
+
const data = await res.json();
|
|
485
|
+
saveGlobalConfig({ token: data.token, email: data.email, serverUrl });
|
|
486
|
+
spin.stop();
|
|
487
|
+
const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
|
|
488
|
+
console.log(sep);
|
|
489
|
+
console.log(` ${c.green}✓${c.reset} Logged in as ${c.bold}${data.email}${c.reset}`);
|
|
490
|
+
console.log(` ${c.dim}credentials saved to ${GLOBAL_CONFIG_FILE}${c.reset}`);
|
|
491
|
+
console.log(sep + "\n");
|
|
492
|
+
}
|
|
493
|
+
catch (e) {
|
|
494
|
+
spin.fail(`Connection failed: ${e.message}`);
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
499
|
+
// edge-ota logout
|
|
500
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
501
|
+
program
|
|
502
|
+
.command("logout")
|
|
503
|
+
.description("Remove stored credentials")
|
|
504
|
+
.action(() => {
|
|
505
|
+
printBanner();
|
|
506
|
+
const cfg = loadGlobalConfig();
|
|
507
|
+
clearGlobalConfig();
|
|
508
|
+
if (cfg?.email) {
|
|
509
|
+
console.log(` ${c.green}✓${c.reset} Logged out from ${c.bold}${cfg.email}${c.reset}\n`);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
console.log(` ${c.dim}Already logged out.${c.reset}\n`);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
218
516
|
// edge-ota init
|
|
219
|
-
//
|
|
517
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
220
518
|
program
|
|
221
519
|
.command("init")
|
|
222
|
-
.description("
|
|
223
|
-
.option("-s, --server <url>", "EdgeOTA server URL")
|
|
224
|
-
.option("--project <id>", "EdgeOTA Project ID (optional)")
|
|
520
|
+
.description("Register project on EdgeOTA, generate signing keys, configure app.json")
|
|
521
|
+
.option("-s, --server <url>", "EdgeOTA server URL (overrides logged-in server)")
|
|
225
522
|
.action(async (options) => {
|
|
226
523
|
printBanner();
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (!projectId && !options.server) {
|
|
235
|
-
const pAnswer = await askQuestion(` project id (optional, press Enter to skip): `);
|
|
236
|
-
if (pAnswer.trim())
|
|
237
|
-
projectId = pAnswer.trim();
|
|
238
|
-
}
|
|
239
|
-
serverUrl = serverUrl.replace(/\/$/, "");
|
|
240
|
-
console.log(` ${colors.dim}generating ECDSA P-256 key pair...${colors.reset}`);
|
|
241
|
-
const keys = await generateECDSAKeyPair();
|
|
242
|
-
const config = { serverUrl, publicKey: keys.publicKey };
|
|
243
|
-
if (projectId)
|
|
244
|
-
config.projectId = projectId;
|
|
524
|
+
const token = resolveToken();
|
|
525
|
+
if (!token) {
|
|
526
|
+
console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.\n`);
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
const globalCfg = loadGlobalConfig();
|
|
530
|
+
const serverUrl = (options.server || globalCfg?.serverUrl || "https://api.edge-ota.renbo.site").replace(/\/$/, "");
|
|
245
531
|
const cwd = process.cwd();
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
532
|
+
// Fetch existing projects
|
|
533
|
+
spin.start("fetching existing projects from server");
|
|
534
|
+
let projects = [];
|
|
535
|
+
try {
|
|
536
|
+
const res = await fetch(`${serverUrl}/api/projects`, {
|
|
537
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
538
|
+
});
|
|
539
|
+
if (res.ok) {
|
|
540
|
+
projects = await res.json();
|
|
541
|
+
}
|
|
542
|
+
spin.stop();
|
|
543
|
+
}
|
|
544
|
+
catch (e) {
|
|
545
|
+
spin.fail(`Failed to fetch projects: ${e.message}`);
|
|
546
|
+
// Continue with creation if list fails
|
|
547
|
+
}
|
|
548
|
+
let projectId = null;
|
|
549
|
+
let projectName = "";
|
|
550
|
+
if (projects.length > 0) {
|
|
551
|
+
const options = projects.map(p => ({
|
|
552
|
+
label: `${p.name} ${c.dim}(id: ${p.id.slice(0, 8)}...)${c.reset}`,
|
|
553
|
+
value: p
|
|
554
|
+
}));
|
|
555
|
+
options.push({
|
|
556
|
+
label: `${c.green}Create a new project...${c.reset}`,
|
|
557
|
+
value: null
|
|
558
|
+
});
|
|
559
|
+
const selected = await selectOption("Select a project to associate with this app:", options);
|
|
560
|
+
if (selected) {
|
|
561
|
+
projectId = selected.id;
|
|
562
|
+
projectName = selected.name;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const keys = await generateECDSAKeyPair();
|
|
566
|
+
if (projectId) {
|
|
567
|
+
// Re-initialize existing project: update public key on the server
|
|
568
|
+
spin.start(`associating project "${projectName}"`);
|
|
569
|
+
try {
|
|
570
|
+
const res = await fetch(`${serverUrl}/api/projects/${projectId}`, {
|
|
571
|
+
method: "PUT",
|
|
572
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
573
|
+
body: JSON.stringify({ name: projectName, publicKey: keys.publicKey }),
|
|
574
|
+
});
|
|
575
|
+
if (!res.ok) {
|
|
576
|
+
const text = await res.text();
|
|
577
|
+
spin.fail(`Failed to update project public key on server: ${text}`);
|
|
578
|
+
process.exit(1);
|
|
579
|
+
}
|
|
580
|
+
spin.stop(`associated project "${projectName}"`);
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
spin.fail(`Connection error: ${e.message}`);
|
|
584
|
+
process.exit(1);
|
|
254
585
|
}
|
|
255
586
|
}
|
|
256
587
|
else {
|
|
257
|
-
|
|
588
|
+
// Create new project
|
|
589
|
+
let suggestedName = path.basename(cwd);
|
|
590
|
+
try {
|
|
591
|
+
const data = JSON.parse(fs.readFileSync(path.resolve(cwd, "app.json"), "utf-8"));
|
|
592
|
+
suggestedName = data?.expo?.name || suggestedName;
|
|
593
|
+
}
|
|
594
|
+
catch { /* ignore */ }
|
|
595
|
+
projectName = (await ask(` ${c.dim}project name [${suggestedName}]:${c.reset} `)) || suggestedName;
|
|
596
|
+
spin.start("registering project on server");
|
|
597
|
+
try {
|
|
598
|
+
const res = await fetch(`${serverUrl}/api/projects`, {
|
|
599
|
+
method: "POST",
|
|
600
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
601
|
+
body: JSON.stringify({ name: projectName, publicKey: keys.publicKey }),
|
|
602
|
+
});
|
|
603
|
+
if (!res.ok) {
|
|
604
|
+
const text = await res.text();
|
|
605
|
+
spin.fail(`Failed to register project: ${text}`);
|
|
606
|
+
process.exit(1);
|
|
607
|
+
}
|
|
608
|
+
const data = await res.json();
|
|
609
|
+
projectId = data.id;
|
|
610
|
+
spin.stop("project registered");
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
spin.fail(`Connection error: ${e.message}`);
|
|
614
|
+
process.exit(1);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// Save private key globally
|
|
618
|
+
if (projectId) {
|
|
619
|
+
savePrivateKey(projectId, keys.privateKey);
|
|
620
|
+
// Update app.json
|
|
621
|
+
updateAppJson(cwd, serverUrl, projectId, keys.publicKey);
|
|
622
|
+
const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
|
|
623
|
+
console.log(`\n${sep}`);
|
|
624
|
+
console.log(` ${c.green}✓${c.reset} ${c.bold}Initialised${c.reset}`);
|
|
625
|
+
console.log(sep);
|
|
626
|
+
console.log(` project ${c.dim}${projectId}${c.reset}`);
|
|
627
|
+
console.log(` server ${c.dim}${serverUrl}${c.reset}`);
|
|
628
|
+
console.log(` key file ${c.dim}${path.join(GLOBAL_KEYS_DIR, `${projectId}.key`)}${c.reset}`);
|
|
629
|
+
console.log(sep + "\n");
|
|
630
|
+
console.log(` ${c.dim}Next step:${c.reset} run ${c.cyan}edge-ota push${c.reset} to publish your first update.\n`);
|
|
258
631
|
}
|
|
259
|
-
updateAppJson(cwd, serverUrl, projectId);
|
|
260
|
-
const targetUrlStr = projectId
|
|
261
|
-
? `${serverUrl}/api/projects/${projectId}/updates`
|
|
262
|
-
: `${serverUrl}/api/updates`;
|
|
263
|
-
const sep = `${colors.dim}${"-".repeat(48)}${colors.reset}`;
|
|
264
|
-
console.log(`\n ${colors.green}✓${colors.reset} ${colors.bold}initialised${colors.reset}`);
|
|
265
|
-
console.log(sep);
|
|
266
|
-
console.log(` config ${colors.cyan}edge-ota.config.json${colors.reset}`);
|
|
267
|
-
console.log(` key ${colors.yellow}.edge-ota.private.key${colors.reset} ${colors.dim}(keep secret)${colors.reset}`);
|
|
268
|
-
console.log(` endpoint ${colors.dim}${targetUrlStr}${colors.reset}`);
|
|
269
|
-
console.log(sep);
|
|
270
|
-
console.log(`\n ${colors.bold}public key${colors.reset} ${colors.dim}paste into dashboard → Settings → General${colors.reset}\n`);
|
|
271
|
-
console.log(`${colors.dim}${keys.publicKey}${colors.reset}\n`);
|
|
272
632
|
});
|
|
273
|
-
//
|
|
633
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
274
634
|
// edge-ota push
|
|
275
|
-
//
|
|
635
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
276
636
|
program
|
|
277
637
|
.command("push")
|
|
278
|
-
.description("Export
|
|
638
|
+
.description("Export your Expo bundle and publish an OTA update")
|
|
279
639
|
.option("-c, --channel <channel>", "Deployment channel", "production")
|
|
280
|
-
.option("-r, --runtime <version>", "React Native runtime version (semver)", "1.0.0")
|
|
281
640
|
.option("-p, --platform <platform>", "Target platform: ios | android | all", "all")
|
|
282
|
-
.option("--project <id>", "Project ID (optional, overrides config)")
|
|
283
|
-
.option("-t, --token <token>", "Deploy key token (or set EDGE_OTA_TOKEN env var)")
|
|
284
641
|
.option("--skip-export", "Skip expo export (use existing ./dist directory)")
|
|
285
642
|
.option("--dry-run", "Build and sign the payload but do NOT upload")
|
|
286
643
|
.action(async (options) => {
|
|
287
644
|
const cwd = process.cwd();
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
// Resolve token — CLI flag > env var
|
|
291
|
-
const token = options.token || process.env.EDGE_OTA_TOKEN;
|
|
645
|
+
const token = resolveToken();
|
|
646
|
+
printBanner();
|
|
292
647
|
if (!token) {
|
|
293
|
-
console.error(
|
|
294
|
-
console.error(`
|
|
648
|
+
console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.`);
|
|
649
|
+
console.error(` Or set ${c.dim}EDGE_OTA_TOKEN${c.reset} environment variable.\n`);
|
|
650
|
+
process.exit(1);
|
|
651
|
+
}
|
|
652
|
+
// Auto-detect from app.json
|
|
653
|
+
const appCfg = readAppJson(cwd);
|
|
654
|
+
const { serverUrl, projectId, runtimeVersion } = appCfg;
|
|
655
|
+
// Load private signing key
|
|
656
|
+
const privateKey = projectId ? loadPrivateKey(projectId) : null;
|
|
657
|
+
if (!privateKey) {
|
|
658
|
+
console.error(` ${c.red}✗${c.reset} No signing key found for this project.`);
|
|
659
|
+
if (projectId) {
|
|
660
|
+
console.error(` Expected at: ${c.dim}${path.join(GLOBAL_KEYS_DIR, `${projectId}.key`)}${c.reset}`);
|
|
661
|
+
}
|
|
662
|
+
console.error(` Run ${c.cyan}edge-ota init${c.reset} to set up code signing.\n`);
|
|
295
663
|
process.exit(1);
|
|
296
664
|
}
|
|
297
|
-
// Resolve project ID — CLI flag > config file
|
|
298
|
-
const projectId = options.project || config.projectId;
|
|
299
|
-
// Build the correct upload URL
|
|
300
665
|
const uploadUrl = projectId
|
|
301
|
-
? `${
|
|
302
|
-
: `${
|
|
303
|
-
// ── Step 1: Build ────────────────────────────────────────────────────────
|
|
304
|
-
printBanner();
|
|
666
|
+
? `${serverUrl}/api/projects/${projectId}/updates`
|
|
667
|
+
: `${serverUrl}/api/updates`;
|
|
305
668
|
const distDir = path.resolve(cwd, "dist");
|
|
669
|
+
// ── Step 1: Expo export ──────────────────────────────────────────────────
|
|
306
670
|
if (!options.skipExport) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
671
|
+
spin.start("running expo export");
|
|
672
|
+
await new Promise((resolve, reject) => {
|
|
673
|
+
const proc = spawn("npx", ["expo", "export"], {
|
|
674
|
+
cwd,
|
|
675
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
676
|
+
shell: process.platform === "win32",
|
|
677
|
+
});
|
|
678
|
+
// Filter stdout: only show platform bundle lines
|
|
679
|
+
proc.stdout?.on("data", (chunk) => {
|
|
680
|
+
const text = chunk.toString();
|
|
681
|
+
for (const line of text.split("\n")) {
|
|
682
|
+
const t = line.trim();
|
|
683
|
+
// Show platform bundle summary lines only
|
|
684
|
+
if (t.startsWith("_expo/static/js/") || t.match(/\.(hbc|js)\s+\(\d/)) {
|
|
685
|
+
spin.stop();
|
|
686
|
+
console.log(` ${c.dim}${t}${c.reset}`);
|
|
687
|
+
spin.start("running expo export");
|
|
688
|
+
}
|
|
689
|
+
// Update spinner with bundling progress
|
|
690
|
+
if (t.match(/Bundled \d+ms/)) {
|
|
691
|
+
spin.update("expo export — " + t.replace("Bundled", "").trim());
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
// Suppress stderr noise but capture real errors
|
|
696
|
+
let stderrBuf = "";
|
|
697
|
+
proc.stderr?.on("data", (chunk) => { stderrBuf += chunk.toString(); });
|
|
698
|
+
proc.on("close", code => {
|
|
699
|
+
if (code === 0) {
|
|
700
|
+
spin.stop("expo export complete");
|
|
701
|
+
resolve();
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
spin.fail("expo export failed");
|
|
705
|
+
// Print last few lines of stderr for context
|
|
706
|
+
const lines = stderrBuf.split("\n").filter(Boolean);
|
|
707
|
+
for (const l of lines.slice(-10))
|
|
708
|
+
console.error(` ${c.dim}${l}${c.reset}`);
|
|
709
|
+
reject(new Error(`expo export exited with code ${code}`));
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
}).catch(() => process.exit(1));
|
|
315
713
|
}
|
|
316
714
|
else {
|
|
317
|
-
console.log(` ${
|
|
715
|
+
console.log(` ${c.dim}skip-export — using existing ./dist${c.reset}`);
|
|
318
716
|
}
|
|
319
717
|
if (!fs.existsSync(distDir)) {
|
|
320
|
-
console.error(` ${
|
|
718
|
+
console.error(` ${c.red}✗${c.reset} ./dist not found after export\n`);
|
|
321
719
|
process.exit(1);
|
|
322
720
|
}
|
|
323
|
-
|
|
721
|
+
// ── Step 2: Collect assets ───────────────────────────────────────────────
|
|
722
|
+
spin.start("collecting assets");
|
|
324
723
|
const assets = await collectAssets(distDir);
|
|
325
|
-
|
|
724
|
+
spin.stop(`found ${assets.length} asset(s)`);
|
|
326
725
|
const platforms = options.platform === "all" ? ["ios", "android"] : [options.platform];
|
|
327
|
-
const sep = `${
|
|
726
|
+
const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
|
|
727
|
+
// ── Step 3: Per-platform upload ──────────────────────────────────────────
|
|
328
728
|
for (const platform of platforms) {
|
|
329
729
|
console.log(`\n${sep}`);
|
|
330
|
-
console.log(` ${
|
|
730
|
+
console.log(` ${c.bold}${platform}${c.reset}`);
|
|
331
731
|
console.log(sep);
|
|
332
732
|
const bundlePath = findBundle(distDir, platform);
|
|
333
733
|
if (!bundlePath) {
|
|
334
|
-
console.warn(` ${
|
|
734
|
+
console.warn(` ${c.yellow}⚠${c.reset} no bundle found for ${platform}, skipping`);
|
|
335
735
|
continue;
|
|
336
736
|
}
|
|
337
737
|
const bundleHash = await hashFile(bundlePath);
|
|
738
|
+
const bundleSize = (fs.statSync(bundlePath).size / 1024 / 1024).toFixed(2);
|
|
739
|
+
console.log(` bundle ${c.dim}${bundleHash.slice(0, 16)}… ${bundleSize} MB${c.reset}`);
|
|
338
740
|
const payloadObj = {
|
|
339
741
|
channel: options.channel,
|
|
340
|
-
runtimeVersion
|
|
742
|
+
runtimeVersion,
|
|
341
743
|
platform,
|
|
342
744
|
bundleHash,
|
|
343
745
|
timestamp: Date.now(),
|
|
344
746
|
assetCount: assets.length,
|
|
345
|
-
publicKey:
|
|
747
|
+
publicKey: appCfg.publicKey,
|
|
346
748
|
};
|
|
347
749
|
const payloadStr = JSON.stringify(payloadObj);
|
|
348
|
-
|
|
750
|
+
spin.start("signing");
|
|
349
751
|
const signature = await signPayload(payloadStr, privateKey);
|
|
350
|
-
|
|
752
|
+
spin.stop(`signed ${c.dim}${signature.slice(0, 20)}…${c.reset}`);
|
|
351
753
|
if (options.dryRun) {
|
|
352
|
-
console.log(` ${
|
|
353
|
-
console.log(" payload ", payloadObj);
|
|
754
|
+
console.log(` ${c.yellow}dry-run${c.reset} upload skipped`);
|
|
354
755
|
continue;
|
|
355
756
|
}
|
|
356
|
-
|
|
757
|
+
spin.start("uploading");
|
|
357
758
|
const bundleBuffer = fs.readFileSync(bundlePath);
|
|
358
759
|
const form = new FormData();
|
|
359
760
|
form.append("bundle", new Blob([bundleBuffer], { type: "application/javascript" }), `bundle-${platform}.hbc`);
|
|
360
761
|
form.append("payload", payloadStr);
|
|
361
762
|
form.append("signature", signature);
|
|
362
763
|
form.append("platform", platform);
|
|
363
|
-
console.log(` uploading ${colors.dim}${uploadUrl}${colors.reset}`);
|
|
364
764
|
const response = await fetch(uploadUrl, {
|
|
365
765
|
method: "POST",
|
|
366
766
|
headers: {
|
|
367
|
-
|
|
767
|
+
Authorization: `Bearer ${token}`,
|
|
368
768
|
...(projectId ? { "X-Project-Id": projectId } : {})
|
|
369
769
|
},
|
|
370
|
-
body: form
|
|
770
|
+
body: form,
|
|
771
|
+
}).catch(e => {
|
|
772
|
+
spin.fail(`Upload failed: ${e.message}`);
|
|
773
|
+
process.exit(1);
|
|
371
774
|
});
|
|
372
|
-
if (response.ok) {
|
|
373
|
-
const body = await response.json().catch(() => ({}));
|
|
374
|
-
const updateId = body.updateId || "unknown";
|
|
375
|
-
console.log(` ${colors.green}✓${colors.reset} deployed`);
|
|
376
|
-
console.log(` id ${colors.dim}${updateId}${colors.reset}`);
|
|
377
|
-
console.log(` channel ${options.channel}`);
|
|
378
|
-
console.log(` runtime ${options.runtime}`);
|
|
379
|
-
console.log(` bundle ${colors.dim}${bundleHash.slice(0, 16)}…${colors.reset}`);
|
|
380
|
-
}
|
|
381
|
-
else {
|
|
775
|
+
if (!response.ok) {
|
|
382
776
|
const text = await response.text();
|
|
383
|
-
|
|
777
|
+
spin.fail(`Upload failed (HTTP ${response.status}): ${text}`);
|
|
384
778
|
process.exit(1);
|
|
385
779
|
}
|
|
780
|
+
const body = await response.json().catch(() => ({}));
|
|
781
|
+
spin.stop("uploaded");
|
|
782
|
+
console.log(` ${c.green}✓${c.reset} deployed`);
|
|
783
|
+
console.log(` id ${c.dim}${body.updateId || "—"}${c.reset}`);
|
|
784
|
+
console.log(` channel ${options.channel}`);
|
|
785
|
+
console.log(` runtime ${runtimeVersion}`);
|
|
386
786
|
}
|
|
387
|
-
console.log(`\n${
|
|
388
|
-
console.log(` ${
|
|
389
|
-
console.log(`${
|
|
787
|
+
console.log(`\n${sep}`);
|
|
788
|
+
console.log(` ${c.green}✓${c.reset} ${c.bold}done${c.reset} ${c.dim}update will be applied on next OTA sync${c.reset}`);
|
|
789
|
+
console.log(`${sep}\n`);
|
|
390
790
|
});
|
|
391
|
-
//
|
|
791
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
392
792
|
// edge-ota status
|
|
393
|
-
//
|
|
793
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
394
794
|
program
|
|
395
795
|
.command("status")
|
|
396
|
-
.description("List recent
|
|
397
|
-
.option("-t, --token <token>", "Auth token (or set EDGE_OTA_TOKEN env var)")
|
|
796
|
+
.description("List recent deployments for this project")
|
|
398
797
|
.option("-n, --limit <n>", "Number of releases to show", "10")
|
|
399
|
-
.option("--project <id>", "Project ID (optional)")
|
|
400
798
|
.action(async (options) => {
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
const token = options.token || process.env.EDGE_OTA_TOKEN;
|
|
404
|
-
const projectId = options.project || config.projectId;
|
|
799
|
+
printBanner();
|
|
800
|
+
const token = resolveToken();
|
|
405
801
|
if (!token) {
|
|
406
|
-
console.error(` ${
|
|
802
|
+
console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.\n`);
|
|
407
803
|
process.exit(1);
|
|
408
804
|
}
|
|
805
|
+
const cwd = process.cwd();
|
|
806
|
+
const appCfg = readAppJson(cwd);
|
|
807
|
+
const { serverUrl, projectId } = appCfg;
|
|
409
808
|
const headers = { Authorization: `Bearer ${token}` };
|
|
410
809
|
if (projectId)
|
|
411
810
|
headers["x-project-id"] = projectId;
|
|
412
|
-
|
|
811
|
+
spin.start("fetching releases");
|
|
812
|
+
const res = await fetch(`${serverUrl}/api/releases`, { headers }).catch(e => {
|
|
813
|
+
spin.fail(`Connection failed: ${e.message}`);
|
|
814
|
+
process.exit(1);
|
|
815
|
+
});
|
|
413
816
|
if (!res.ok) {
|
|
414
|
-
|
|
817
|
+
spin.fail(`Failed to fetch releases (HTTP ${res.status})`);
|
|
415
818
|
process.exit(1);
|
|
416
819
|
}
|
|
417
|
-
const releases =
|
|
820
|
+
const releases = await res.json();
|
|
821
|
+
spin.stop();
|
|
418
822
|
if (!releases.length) {
|
|
419
|
-
console.log(` ${
|
|
823
|
+
console.log(` ${c.dim}No releases found.${c.reset}\n`);
|
|
420
824
|
return;
|
|
421
825
|
}
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
826
|
+
const limit = parseInt(options.limit);
|
|
827
|
+
const rows = releases.slice(0, limit).map((r) => ({
|
|
828
|
+
ID: r.id?.slice(0, 8) ?? "—",
|
|
829
|
+
Channel: r.channel ?? "—",
|
|
830
|
+
Runtime: r.runtime ?? "—",
|
|
831
|
+
Platform: r.platform ?? "all",
|
|
832
|
+
Created: r.created_at ? new Date(r.created_at).toLocaleString() : "—",
|
|
429
833
|
}));
|
|
430
834
|
console.table(rows);
|
|
431
835
|
});
|
|
432
|
-
//
|
|
836
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
433
837
|
// edge-ota keygen
|
|
434
|
-
//
|
|
838
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
435
839
|
program
|
|
436
840
|
.command("keygen")
|
|
437
|
-
.description("Generate a fresh ECDSA key pair (prints to stdout, does not write files)")
|
|
841
|
+
.description("Generate a fresh ECDSA P-256 key pair (prints to stdout, does not write files)")
|
|
438
842
|
.action(async () => {
|
|
439
843
|
const keys = await generateECDSAKeyPair();
|
|
440
|
-
const sep = `${
|
|
844
|
+
const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
|
|
441
845
|
console.log(`\n${sep}`);
|
|
442
|
-
console.log(` ${
|
|
846
|
+
console.log(` ${c.bold}private key${c.reset} ${c.dim}keep secret — never commit${c.reset}`);
|
|
443
847
|
console.log(sep);
|
|
444
848
|
console.log(keys.privateKey);
|
|
445
849
|
console.log(`\n${sep}`);
|
|
446
|
-
console.log(` ${
|
|
850
|
+
console.log(` ${c.bold}public key${c.reset} ${c.dim}paste into dashboard → Settings → General${c.reset}`);
|
|
447
851
|
console.log(sep);
|
|
448
852
|
console.log(keys.publicKey);
|
|
449
853
|
});
|