@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/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
- * Phase 1: Build / Upload
6
- * ─────────────────────────────────────────────────────────────────────────
7
- * 1. `edge-ota init` – Generate ECDSA P-256 key pair, write config + private key.
8
- * 2. `edge-ota push` – Run `expo export`, collect JS bundle + assets, compute
9
- * per-asset SHA-256 hashes, sign the upload payload with the
10
- * private key, POST a multipart/form-data to /api/updates.
11
- * 3. `edge-ota status` – List recent releases and their channel assignments.
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 { execSync } from "child_process";
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
- // ─── Auto-load .env from the project root ────────────────────────────────────
36
- // Lets users store EDGE_OTA_TOKEN in a .env file without shell exports.
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 lines = fs.readFileSync(envPath, "utf8").split("\n");
41
- for (const line of lines) {
42
- const trimmed = line.trim();
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 eqIdx = trimmed.indexOf("=");
46
- if (eqIdx === -1)
77
+ const eq = t.indexOf("=");
78
+ if (eq === -1)
47
79
  continue;
48
- const key = trimmed.slice(0, eqIdx).trim();
49
- const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, "");
50
- if (!(key in process.env))
51
- process.env[key] = val;
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 if .env is unreadable */ }
56
- // ─── ANSI Styling Helpers ───────────────────────────────────────────────────
57
- const colors = {
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
- function printBanner() {
74
- const line = `${colors.dim}${"-".repeat(48)}${colors.reset}`;
75
- console.log(`\n${line}`);
76
- console.log(` ${colors.bold}${colors.white}edge-ota${colors.reset} ${colors.dim}v0.2.4 · Zero-SDK OTA for Expo${colors.reset}`);
77
- console.log(` ${colors.dim}by Renbo Studios · renbostudios.com${colors.reset}`);
78
- console.log(`${line}\n`);
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
- function askQuestion(query) {
81
- const rl = readline.createInterface({
82
- input: process.stdin,
83
- output: process.stdout
84
- });
85
- return new Promise((resolve) => {
86
- rl.question(query, (answer) => {
87
- rl.close();
88
- resolve(answer.trim());
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 updateAppJson(cwd, serverUrl, projectId) {
166
+ function readAppJson(cwd) {
93
167
  const appJsonPath = path.resolve(cwd, "app.json");
94
168
  if (!fs.existsSync(appJsonPath)) {
95
- console.log("⚠️ app.json not found in this directory. Skipping auto-update.");
96
- return;
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
- const raw = fs.readFileSync(appJsonPath, "utf-8");
100
- const data = JSON.parse(raw);
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 updateUrl = projectId
106
- ? `${serverUrl.replace(/\/$/, "")}/api/projects/${projectId}/updates`
107
- : `${serverUrl.replace(/\/$/, "")}/api/updates`;
108
- data.expo.updates.url = updateUrl;
109
- fs.writeFileSync(appJsonPath, JSON.stringify(data, null, 2), "utf-8");
110
- console.log(` ${colors.dim}app.json${colors.reset} updates.url set`);
111
- }
112
- catch (error) {
113
- console.error(` ${colors.red}error${colors.reset} Failed to update app.json: ${error.message}`);
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
- function loadConfig(cwd) {
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
- function loadPrivateKey(cwd) {
125
- const keyPath = path.resolve(cwd, ".edge-ota.private.key");
126
- if (!fs.existsSync(keyPath)) {
127
- console.error("❌ .edge-ota.private.key not found. Run `edge-ota init` first.");
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
- // ─── Archive helper ──────────────────────────────────────────────────────────
133
- function zipDirectory(sourceDir, outPath) {
134
- return new Promise((resolve, reject) => {
135
- const archive = archiver("zip", { zlib: { level: 9 } });
136
- const stream = createWriteStream(outPath);
137
- archive.directory(sourceDir, false);
138
- archive.on("error", reject);
139
- stream.on("close", resolve);
140
- archive.pipe(stream);
141
- archive.finalize();
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
- // ─── SHA-256 of a file ───────────────────────────────────────────────────────
145
- async function hashFile(filePath) {
146
- const buffer = fs.readFileSync(filePath);
147
- return sha256Hex(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
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
- const stat = fs.statSync(full);
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
- const ct = CONTENT_TYPES[ext] || "application/octet-stream";
177
- const key = path.relative(distDir, full).replace(/\\/g, "/");
178
- entries.push({ localPath: full, key, contentType: ct, hash: "" });
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
- // Hash all assets in parallel
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
- // ─── CLI definition ──────────────────────────────────────────────────────────
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, self-hostable OTA update platform for Expo")
216
- .version("0.2.0");
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("Generate an ECDSA P-256 key pair and write edge-ota.config.json")
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
- let serverUrl = options.server;
228
- let projectId = options.project;
229
- if (!serverUrl) {
230
- console.log(` ${colors.dim}no --server flag provided${colors.reset}`);
231
- const answer = await askQuestion(` server url [https://api.edge-ota.renbo.site]: `);
232
- serverUrl = answer.trim() || "https://api.edge-ota.renbo.site";
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
- fs.writeFileSync("edge-ota.config.json", JSON.stringify(config, null, 2));
247
- fs.writeFileSync(".edge-ota.private.key", keys.privateKey, { mode: 0o600 });
248
- const gitignorePath = ".gitignore";
249
- const gitignoreEntry = ".edge-ota.private.key";
250
- if (fs.existsSync(gitignorePath)) {
251
- const current = fs.readFileSync(gitignorePath, "utf-8");
252
- if (!current.includes(gitignoreEntry)) {
253
- fs.appendFileSync(gitignorePath, `\n${gitignoreEntry}\n`);
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
- fs.writeFileSync(gitignorePath, `${gitignoreEntry}\n`);
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 the Expo bundle, compute per-asset hashes, sign & upload to the EdgeOTA server")
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 config = loadConfig(cwd);
289
- const privateKey = loadPrivateKey(cwd);
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(`${colors.bold}${colors.red}❌ Auth token required. Use --token <token> or set EDGE_OTA_TOKEN env var.${colors.reset}`);
294
- console.error(` Generate a deploy key from your dashboard → Keys page.`);
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
- ? `${config.serverUrl}/api/projects/${projectId}/updates`
302
- : `${config.serverUrl}/api/updates`;
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
- console.log(` ${colors.dim}running expo export...${colors.reset}`);
308
- try {
309
- execSync("npx expo export", { stdio: "inherit", cwd });
310
- }
311
- catch {
312
- console.error(` ${colors.red}error${colors.reset} expo export failed`);
313
- process.exit(1);
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(` ${colors.dim}skipping expo export (--skip-export)${colors.reset}`);
715
+ console.log(` ${c.dim}skip-export — using existing ./dist${c.reset}`);
318
716
  }
319
717
  if (!fs.existsSync(distDir)) {
320
- console.error(` ${colors.red}error${colors.reset} ./dist not found after export`);
718
+ console.error(` ${c.red}✗${c.reset} ./dist not found after export\n`);
321
719
  process.exit(1);
322
720
  }
323
- console.log(` ${colors.dim}collecting assets...${colors.reset}`);
721
+ // ── Step 2: Collect assets ───────────────────────────────────────────────
722
+ spin.start("collecting assets");
324
723
  const assets = await collectAssets(distDir);
325
- console.log(` ${colors.dim}found ${assets.length} asset(s)${projectId ? ` · project ${projectId}` : ""}${colors.reset}`);
724
+ spin.stop(`found ${assets.length} asset(s)`);
326
725
  const platforms = options.platform === "all" ? ["ios", "android"] : [options.platform];
327
- const sep = `${colors.dim}${"-".repeat(48)}${colors.reset}`;
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(` ${colors.bold}${platform}${colors.reset}`);
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(` ${colors.yellow}warn${colors.reset} no bundle found for ${platform}, skipping`);
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: options.runtime,
742
+ runtimeVersion,
341
743
  platform,
342
744
  bundleHash,
343
745
  timestamp: Date.now(),
344
746
  assetCount: assets.length,
345
- publicKey: config.publicKey
747
+ publicKey: appCfg.publicKey,
346
748
  };
347
749
  const payloadStr = JSON.stringify(payloadObj);
348
- console.log(` signing ECDSA P-256`);
750
+ spin.start("signing");
349
751
  const signature = await signPayload(payloadStr, privateKey);
350
- console.log(` sig ${colors.dim}${signature.slice(0, 24)}…${colors.reset}`);
752
+ spin.stop(`signed ${c.dim}${signature.slice(0, 20)}…${c.reset}`);
351
753
  if (options.dryRun) {
352
- console.log(` ${colors.yellow}dry-run${colors.reset} upload skipped`);
353
- console.log(" payload ", payloadObj);
754
+ console.log(` ${c.yellow}dry-run${c.reset} upload skipped`);
354
755
  continue;
355
756
  }
356
- console.log(` packing bundle`);
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
- "Authorization": `Bearer ${token}`,
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
- console.error(` ${colors.red}error${colors.reset} upload failed (HTTP ${response.status}): ${text}`);
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${colors.dim}${"-".repeat(48)}${colors.reset}`);
388
- console.log(` ${colors.green}✓${colors.reset} ${colors.bold}done${colors.reset} update will be applied on next OTA sync`);
389
- console.log(`${colors.dim}${"-".repeat(48)}${colors.reset}\n`);
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 releases from the EdgeOTA server")
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
- const cwd = process.cwd();
402
- const config = loadConfig(cwd);
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(` ${colors.red}error${colors.reset} token required — set EDGE_OTA_TOKEN in .env or use -t`);
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
- const res = await fetch(`${config.serverUrl}/api/releases`, { headers });
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
- console.error(` ${colors.red}error${colors.reset} failed to fetch releases (HTTP ${res.status})`);
817
+ spin.fail(`Failed to fetch releases (HTTP ${res.status})`);
415
818
  process.exit(1);
416
819
  }
417
- const releases = (await res.json());
820
+ const releases = await res.json();
821
+ spin.stop();
418
822
  if (!releases.length) {
419
- console.log(` ${colors.dim}no releases found${colors.reset}`);
823
+ console.log(` ${c.dim}No releases found.${c.reset}\n`);
420
824
  return;
421
825
  }
422
- const rows = releases.slice(0, parseInt(options.limit)).map((r) => ({
423
- ID: r.id.slice(0, 8),
424
- Channel: r.channel,
425
- Runtime: r.runtime,
426
- Status: r.status,
427
- Published: r.published,
428
- By: r.publisher
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 = `${colors.dim}${"-".repeat(48)}${colors.reset}`;
844
+ const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
441
845
  console.log(`\n${sep}`);
442
- console.log(` ${colors.bold}private key${colors.reset} ${colors.dim}keep secret — never commit${colors.reset}`);
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(` ${colors.bold}public key${colors.reset} ${colors.dim}paste into dashboard → Settings → General${colors.reset}`);
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
  });