@renbostudios/edge-ota 0.2.8 → 0.3.0

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.
Files changed (2) hide show
  1. package/dist/index.js +189 -47
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -146,7 +146,7 @@ const spin = (() => {
146
146
  };
147
147
  })();
148
148
  // ─── Package version ─────────────────────────────────────────────────────────
149
- let packageVersion = "0.2.8";
149
+ let packageVersion = "0.3.0";
150
150
  try {
151
151
  const pkgPath = path.join(__dirname, "..", "package.json");
152
152
  if (fs.existsSync(pkgPath)) {
@@ -163,6 +163,8 @@ function printBanner() {
163
163
  console.log(` ${c.dim}renbostudios.com${c.reset}`);
164
164
  console.log(`${sep}\n`);
165
165
  }
166
+ // ─── app.json helpers ────────────────────────────────────────────────────────
167
+ const DEFAULT_SERVER = "https://api.ota.renbo.site";
166
168
  function readAppJson(cwd) {
167
169
  const appJsonPath = path.resolve(cwd, "app.json");
168
170
  if (!fs.existsSync(appJsonPath)) {
@@ -193,7 +195,7 @@ function readAppJson(cwd) {
193
195
  console.error(` Run ${c.cyan}edge-ota init${c.reset} to set this up.\n`);
194
196
  process.exit(1);
195
197
  }
196
- // Parse serverUrl and projectId from the updates URL
198
+ // Parse serverUrl and projectId from the updates URL.
197
199
  // Expected format: https://<host>/api/projects/<uuid>/updates
198
200
  // or: https://<host>/api/updates
199
201
  let serverUrl;
@@ -213,7 +215,14 @@ function readAppJson(cwd) {
213
215
  console.error(` ${c.red}✗${c.reset} Could not parse ${c.dim}expo.updates.url${c.reset} in app.json`);
214
216
  process.exit(1);
215
217
  }
216
- return { serverUrl, projectId, runtimeVersion, publicKey: expo.updates?.publicKey };
218
+ // ── Project-level server override ──
219
+ // expo.extra.edgeOtaServer lets users point a specific project at a
220
+ // different (self-hosted) EdgeOTA instance without re-logging in.
221
+ const projectServer = expo?.extra?.edgeOtaServer;
222
+ if (projectServer) {
223
+ serverUrl = projectServer.replace(/\/$/, "");
224
+ }
225
+ return { serverUrl, projectId, runtimeVersion, publicKey: expo?.extra?.edgeOtaPublicKey };
217
226
  }
218
227
  function updateAppJson(cwd, serverUrl, projectId, publicKey) {
219
228
  const appJsonPath = path.resolve(cwd, "app.json");
@@ -225,6 +234,8 @@ function updateAppJson(cwd, serverUrl, projectId, publicKey) {
225
234
  data.expo = {};
226
235
  if (!data.expo.updates)
227
236
  data.expo.updates = {};
237
+ if (!data.expo.extra)
238
+ data.expo.extra = {};
228
239
  const cleanUrl = serverUrl.replace(/\/$/, "");
229
240
  data.expo.updates.url = projectId
230
241
  ? `${cleanUrl}/api/projects/${projectId}/updates`
@@ -232,13 +243,15 @@ function updateAppJson(cwd, serverUrl, projectId, publicKey) {
232
243
  data.expo.updates.checkAutomatically = data.expo.updates.checkAutomatically ?? "ON_LOAD";
233
244
  data.expo.updates.fallbackToCacheTimeout = data.expo.updates.fallbackToCacheTimeout ?? 30000;
234
245
  data.expo.updates.requestHeaders = data.expo.updates.requestHeaders ?? { "expo-channel-name": "production" };
246
+ // Store the server URL in expo.extra.edgeOtaServer so users can easily
247
+ // override which EdgeOTA instance this project points to without re-running
248
+ // `edge-ota login`. They can edit this value directly in app.json.
249
+ data.expo.extra.edgeOtaServer = cleanUrl;
235
250
  if (publicKey) {
236
- data.expo.updates.codeSigningCertificate = undefined; // clear legacy
237
- data.expo.extra = data.expo.extra ?? {};
238
251
  data.expo.extra.edgeOtaPublicKey = publicKey;
239
252
  }
240
253
  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}`);
254
+ console.log(` ${c.dim}app.json${c.reset} updated ${c.dim}expo.updates.url${c.reset} + ${c.dim}expo.extra.edgeOtaServer${c.reset}`);
242
255
  }
243
256
  catch (e) {
244
257
  console.error(` ${c.yellow}⚠${c.reset} Could not update app.json: ${e.message}`);
@@ -284,6 +297,75 @@ function askSecret(query) {
284
297
  }
285
298
  });
286
299
  }
300
+ function selectOption(question, options) {
301
+ return new Promise((resolve) => {
302
+ if (!process.stdin.isTTY) {
303
+ console.log(` ${c.bold}${question}${c.reset}`);
304
+ options.forEach((opt, idx) => {
305
+ console.log(` [${idx + 1}] ${opt.label}`);
306
+ });
307
+ ask(` selection (1-${options.length}): `).then((choice) => {
308
+ const idx = parseInt(choice) - 1;
309
+ if (!isNaN(idx) && idx >= 0 && idx < options.length) {
310
+ resolve(options[idx].value);
311
+ }
312
+ else {
313
+ resolve(options[0].value);
314
+ }
315
+ });
316
+ return;
317
+ }
318
+ let cursor = 0;
319
+ const hideCursor = "\u001b[?25l";
320
+ const showCursor = "\u001b[?25h";
321
+ let hasRendered = false;
322
+ console.log(` ${c.bold}${question}${c.reset}`);
323
+ function render() {
324
+ if (hasRendered) {
325
+ for (let i = 0; i < options.length; i++) {
326
+ process.stdout.write("\r\u001b[K\u001b[A");
327
+ }
328
+ process.stdout.write("\r\u001b[K");
329
+ }
330
+ hasRendered = true;
331
+ options.forEach((opt, idx) => {
332
+ const isSelected = idx === cursor;
333
+ const marker = isSelected ? `${c.cyan}❯${c.reset}` : " ";
334
+ const text = isSelected ? `${c.bold}${c.cyan}${opt.label}${c.reset}` : `${c.dim}${opt.label}${c.reset}`;
335
+ process.stdout.write(` ${marker} ${text}\n`);
336
+ });
337
+ }
338
+ readline.emitKeypressEvents(process.stdin);
339
+ process.stdin.setRawMode(true);
340
+ process.stdin.resume();
341
+ process.stdout.write(hideCursor);
342
+ render();
343
+ function onKeypress(str, key) {
344
+ if (key.ctrl && key.name === "c") {
345
+ process.stdout.write(showCursor);
346
+ process.stdin.setRawMode(false);
347
+ process.stdin.pause();
348
+ process.exit(0);
349
+ }
350
+ if (key.name === "up" || key.name === "k") {
351
+ cursor = (cursor - 1 + options.length) % options.length;
352
+ render();
353
+ }
354
+ else if (key.name === "down" || key.name === "j") {
355
+ cursor = (cursor + 1) % options.length;
356
+ render();
357
+ }
358
+ else if (key.name === "return" || key.name === "enter") {
359
+ process.stdout.write(showCursor);
360
+ process.stdin.setRawMode(false);
361
+ process.stdin.removeListener("keypress", onKeypress);
362
+ process.stdin.pause();
363
+ resolve(options[cursor].value);
364
+ }
365
+ }
366
+ process.stdin.on("keypress", onKeypress);
367
+ });
368
+ }
287
369
  const CONTENT_TYPES = {
288
370
  ".js": "application/javascript",
289
371
  ".hbc": "application/javascript",
@@ -393,11 +475,16 @@ program.action(() => {
393
475
  program
394
476
  .command("login")
395
477
  .description("Authenticate with your EdgeOTA account")
396
- .option("-s, --server <url>", "EdgeOTA server URL", "https://api.edge-ota.renbo.site")
478
+ .option("-s, --server <url>", `EdgeOTA server URL (default: ${DEFAULT_SERVER})`)
397
479
  .action(async (options) => {
398
480
  printBanner();
399
- console.log(` ${c.bold}Sign in to EdgeOTA${c.reset}\n`);
400
- const serverUrl = options.server.replace(/\/$/, "");
481
+ const usingCustomServer = options.server && options.server !== DEFAULT_SERVER;
482
+ console.log(` ${c.bold}Sign in to EdgeOTA${c.reset}`);
483
+ if (usingCustomServer) {
484
+ console.log(` ${c.dim}server: ${options.server}${c.reset}`);
485
+ }
486
+ console.log();
487
+ const serverUrl = (options.server || DEFAULT_SERVER).replace(/\/$/, "");
401
488
  const email = await ask(` ${c.dim}email:${c.reset} `);
402
489
  const password = await askSecret(` ${c.dim}password:${c.reset} `);
403
490
  spin.start("authenticating");
@@ -458,53 +545,108 @@ program
458
545
  process.exit(1);
459
546
  }
460
547
  const globalCfg = loadGlobalConfig();
461
- const serverUrl = (options.server || globalCfg?.serverUrl || "https://api.edge-ota.renbo.site").replace(/\/$/, "");
548
+ const serverUrl = (options.server || globalCfg?.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
462
549
  const cwd = process.cwd();
463
- // Try to read existing app.json for project name suggestion
464
- let suggestedName = path.basename(cwd);
465
- try {
466
- const data = JSON.parse(fs.readFileSync(path.resolve(cwd, "app.json"), "utf-8"));
467
- suggestedName = data?.expo?.name || suggestedName;
468
- }
469
- catch { /* ignore */ }
470
- const projectName = (await ask(` ${c.dim}project name [${suggestedName}]:${c.reset} `)) || suggestedName;
471
- spin.start("generating ECDSA P-256 key pair");
472
- const keys = await generateECDSAKeyPair();
473
- spin.stop("key pair generated");
474
- spin.start("registering project on server");
475
- let projectId;
550
+ // Fetch existing projects
551
+ spin.start("fetching existing projects from server");
552
+ let projects = [];
476
553
  try {
477
554
  const res = await fetch(`${serverUrl}/api/projects`, {
478
- method: "POST",
479
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
480
- body: JSON.stringify({ name: projectName, publicKey: keys.publicKey }),
555
+ headers: { Authorization: `Bearer ${token}` }
481
556
  });
482
- if (!res.ok) {
483
- const text = await res.text();
484
- spin.fail(`Failed to register project: ${text}`);
485
- process.exit(1);
557
+ if (res.ok) {
558
+ projects = await res.json();
486
559
  }
487
- const data = await res.json();
488
- projectId = data.id;
489
- spin.stop("project registered");
560
+ spin.stop();
490
561
  }
491
562
  catch (e) {
492
- spin.fail(`Connection error: ${e.message}`);
493
- process.exit(1);
563
+ spin.fail(`Failed to fetch projects: ${e.message}`);
564
+ // Continue with creation if list fails
565
+ }
566
+ let projectId = null;
567
+ let projectName = "";
568
+ if (projects.length > 0) {
569
+ const options = projects.map(p => ({
570
+ label: `${p.name} ${c.dim}(id: ${p.id.slice(0, 8)}...)${c.reset}`,
571
+ value: p
572
+ }));
573
+ options.push({
574
+ label: `${c.green}Create a new project...${c.reset}`,
575
+ value: null
576
+ });
577
+ const selected = await selectOption("Select a project to associate with this app:", options);
578
+ if (selected) {
579
+ projectId = selected.id;
580
+ projectName = selected.name;
581
+ }
582
+ }
583
+ const keys = await generateECDSAKeyPair();
584
+ if (projectId) {
585
+ // Re-initialize existing project: update public key on the server
586
+ spin.start(`associating project "${projectName}"`);
587
+ try {
588
+ const res = await fetch(`${serverUrl}/api/projects/${projectId}`, {
589
+ method: "PUT",
590
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
591
+ body: JSON.stringify({ name: projectName, publicKey: keys.publicKey }),
592
+ });
593
+ if (!res.ok) {
594
+ const text = await res.text();
595
+ spin.fail(`Failed to update project public key on server: ${text}`);
596
+ process.exit(1);
597
+ }
598
+ spin.stop(`associated project "${projectName}"`);
599
+ }
600
+ catch (e) {
601
+ spin.fail(`Connection error: ${e.message}`);
602
+ process.exit(1);
603
+ }
604
+ }
605
+ else {
606
+ // Create new project
607
+ let suggestedName = path.basename(cwd);
608
+ try {
609
+ const data = JSON.parse(fs.readFileSync(path.resolve(cwd, "app.json"), "utf-8"));
610
+ suggestedName = data?.expo?.name || suggestedName;
611
+ }
612
+ catch { /* ignore */ }
613
+ projectName = (await ask(` ${c.dim}project name [${suggestedName}]:${c.reset} `)) || suggestedName;
614
+ spin.start("registering project on server");
615
+ try {
616
+ const res = await fetch(`${serverUrl}/api/projects`, {
617
+ method: "POST",
618
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
619
+ body: JSON.stringify({ name: projectName, publicKey: keys.publicKey }),
620
+ });
621
+ if (!res.ok) {
622
+ const text = await res.text();
623
+ spin.fail(`Failed to register project: ${text}`);
624
+ process.exit(1);
625
+ }
626
+ const data = await res.json();
627
+ projectId = data.id;
628
+ spin.stop("project registered");
629
+ }
630
+ catch (e) {
631
+ spin.fail(`Connection error: ${e.message}`);
632
+ process.exit(1);
633
+ }
494
634
  }
495
635
  // Save private key globally
496
- savePrivateKey(projectId, keys.privateKey);
497
- // Update app.json
498
- updateAppJson(cwd, serverUrl, projectId, keys.publicKey);
499
- const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
500
- console.log(`\n${sep}`);
501
- console.log(` ${c.green}✓${c.reset} ${c.bold}Initialised${c.reset}`);
502
- console.log(sep);
503
- console.log(` project ${c.dim}${projectId}${c.reset}`);
504
- console.log(` server ${c.dim}${serverUrl}${c.reset}`);
505
- console.log(` key file ${c.dim}${path.join(GLOBAL_KEYS_DIR, `${projectId}.key`)}${c.reset}`);
506
- console.log(sep + "\n");
507
- console.log(` ${c.dim}Next step:${c.reset} run ${c.cyan}edge-ota push${c.reset} to publish your first update.\n`);
636
+ if (projectId) {
637
+ savePrivateKey(projectId, keys.privateKey);
638
+ // Update app.json
639
+ updateAppJson(cwd, serverUrl, projectId, keys.publicKey);
640
+ const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
641
+ console.log(`\n${sep}`);
642
+ console.log(` ${c.green}✓${c.reset} ${c.bold}Initialised${c.reset}`);
643
+ console.log(sep);
644
+ console.log(` project ${c.dim}${projectId}${c.reset}`);
645
+ console.log(` server ${c.dim}${serverUrl}${c.reset}`);
646
+ console.log(` key file ${c.dim}${path.join(GLOBAL_KEYS_DIR, `${projectId}.key`)}${c.reset}`);
647
+ console.log(sep + "\n");
648
+ console.log(` ${c.dim}Next step:${c.reset} run ${c.cyan}edge-ota push${c.reset} to publish your first update.\n`);
649
+ }
508
650
  });
509
651
  // ──────────────────────────────────────────────────────────────────────────────
510
652
  // edge-ota push
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@renbostudios/edge-ota",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "preferGlobal": true,
5
5
  "description": "Zero-SDK, self-hostable OTA update CLI for Expo — push signed JS bundles to any EdgeOTA server",
6
6
  "license": "MIT",