@renbostudios/edge-ota 0.2.7 → 0.2.8
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 +530 -250
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,125 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @renbostudios/edge-ota CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
> Push cryptographically signed JS bundles to any EdgeOTA server — no Expo account required.
|
|
3
|
+
Zero-SDK, self-hostable OTA updates for Expo.
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
```bash
|
|
9
|
-
npm install -g @edge-ota/cli
|
|
10
|
-
# or use without installing:
|
|
11
|
-
npx @edge-ota/cli <command>
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
## Quick Start
|
|
15
|
-
|
|
16
|
-
Run these commands **inside your Expo project directory**:
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
# 1. Generate ECDSA P-256 keys and write config
|
|
20
|
-
edge-ota init
|
|
21
|
-
|
|
22
|
-
# 2. Push your first update
|
|
23
|
-
edge-ota push --channel production --runtime 1.0.0
|
|
24
|
-
|
|
25
|
-
# 3. Check what's deployed
|
|
26
|
-
edge-ota status
|
|
27
|
-
```
|
|
5
|
+
See the [main README](../../README.md) for full documentation.
|
|
28
6
|
|
|
29
7
|
## Commands
|
|
30
8
|
|
|
31
|
-
### `edge-ota init [options]`
|
|
32
|
-
|
|
33
|
-
Generates an ECDSA P-256 key pair, writes `edge-ota.config.json` and `.edge-ota.private.key`, auto-adds the private key to `.gitignore`, and automatically updates your Expo `app.json` configuration file with the new updates URL.
|
|
34
|
-
|
|
35
|
-
If `--server` is not specified, you will be prompted interactively to enter the URL of your hosted or local EdgeOTA server and your optional Project ID.
|
|
36
|
-
|
|
37
|
-
| Option | Default | Description |
|
|
38
|
-
|--------|---------|-------------|
|
|
39
|
-
| `-s, --server <url>` | — | Your EdgeOTA server URL (prompts if omitted) |
|
|
40
|
-
| `--project <id>` | — | Your EdgeOTA Project ID (prompts if omitted, optional) |
|
|
41
|
-
|
|
42
|
-
**Output:**
|
|
43
|
-
- `edge-ota.config.json` — server URL, public key, and optional project ID (safe to commit)
|
|
44
|
-
- `.edge-ota.private.key` — private signing key (**never commit this**)
|
|
45
|
-
- **Auto-configured:** `app.json` is modified to point `expo.updates.url` to your server's updates endpoint. If a Project ID is configured, it points to `/api/projects/:projectId/updates`; otherwise, it falls back to the global `/api/updates` endpoint.
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
### `edge-ota push [options]`
|
|
50
|
-
|
|
51
|
-
Runs `expo export`, hashes every asset with SHA-256, signs the payload with your private key, then uploads the bundle to your server.
|
|
52
|
-
|
|
53
|
-
| Option | Default | Description |
|
|
54
|
-
|--------|---------|-------------|
|
|
55
|
-
| `-c, --channel <name>` | `production` | Deployment channel |
|
|
56
|
-
| `-r, --runtime <version>` | `1.0.0` | Native runtime version (must match `runtimeVersion` in app.json) |
|
|
57
|
-
| `-p, --platform <platform>` | `all` | `ios`, `android`, or `all` |
|
|
58
|
-
| `--project <id>` | — | Project ID (overrides value in `edge-ota.config.json`) |
|
|
59
|
-
| `--skip-export` | — | Skip `expo export` — use existing `./dist` |
|
|
60
|
-
| `--dry-run` | — | Sign the payload but don't upload |
|
|
61
|
-
|
|
62
|
-
**Example — CI/CD:**
|
|
63
|
-
```bash
|
|
64
|
-
edge-ota push \
|
|
65
|
-
--channel production \
|
|
66
|
-
--runtime 1.2.0 \
|
|
67
|
-
--platform all
|
|
68
9
|
```
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
| Option | Default | Description |
|
|
77
|
-
|--------|---------|-------------|
|
|
78
|
-
| `-t, --token <token>` | `$EDGE_OTA_TOKEN` | Auth token from the dashboard |
|
|
79
|
-
| `-n, --limit <n>` | `10` | Number of releases to show |
|
|
80
|
-
| `--project <id>` | — | Project ID to query (overrides config) |
|
|
81
|
-
|
|
82
|
-
```bash
|
|
83
|
-
export EDGE_OTA_TOKEN="your-dashboard-token"
|
|
84
|
-
edge-ota status
|
|
10
|
+
edge-ota login # Log in globally
|
|
11
|
+
edge-ota logout # Log out
|
|
12
|
+
edge-ota init # Set up a new project
|
|
13
|
+
edge-ota push # Publish an OTA update
|
|
14
|
+
edge-ota status # View recent deployments
|
|
15
|
+
edge-ota keygen # Generate a new key pair
|
|
85
16
|
```
|
|
86
17
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
### `edge-ota keygen`
|
|
90
|
-
|
|
91
|
-
Prints a fresh ECDSA P-256 key pair to stdout without writing any files. Useful for generating secrets to store in CI/CD environment variables.
|
|
18
|
+
## Install
|
|
92
19
|
|
|
93
20
|
```bash
|
|
94
|
-
edge-ota
|
|
21
|
+
npm install -g @renbostudios/edge-ota
|
|
95
22
|
```
|
|
96
23
|
|
|
97
24
|
---
|
|
98
25
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
1. **Build** — `expo export` produces a JS bundle + assets in `./dist/`
|
|
102
|
-
2. **Hash** — every file gets a SHA-256 content hash (enables deduplication on the server)
|
|
103
|
-
3. **Sign** — the upload payload (`{ channel, runtimeVersion, bundleHash, ... }`) is signed with your ECDSA P-256 private key
|
|
104
|
-
4. **Upload** — `POST /api/updates` with `multipart/form-data` containing the bundle zip, payload JSON, and signature
|
|
105
|
-
5. **Serve** — when your app checks for updates, the server verifies the stored signature and returns a signed manifest
|
|
106
|
-
6. **Verify** — the Expo SDK verifies the manifest signature and bundle hash before swapping the active bundle
|
|
107
|
-
|
|
108
|
-
## Expo App Setup
|
|
109
|
-
|
|
110
|
-
Add to your `app.json`:
|
|
111
|
-
|
|
112
|
-
```json
|
|
113
|
-
{
|
|
114
|
-
"expo": {
|
|
115
|
-
"updates": {
|
|
116
|
-
"url": "https://your-edge-ota-server.com/api/updates",
|
|
117
|
-
"runtimeVersion": { "policy": "nativeVersion" }
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
## License
|
|
124
|
-
|
|
125
|
-
MIT
|
|
26
|
+
by **[Renbo Studios](https://renbostudios.com)** — Mobile App Development Studio
|
package/dist/core/manifest.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* EdgeOTA Core - Expo Updates Protocol v1 Manifest Engine
|
|
3
3
|
*
|
|
4
|
-
* Implements the exact wire format consumed by expo-updates SDK
|
|
4
|
+
* Implements the exact wire format consumed by expo-updates SDK,
|
|
5
|
+
* matching the official expo/custom-expo-updates-server reference:
|
|
5
6
|
* - EAS Update-compatible manifest structure
|
|
6
|
-
* - Multipart/mixed response body with
|
|
7
|
+
* - Multipart/mixed response body with FormData-compatible boundary
|
|
7
8
|
* - expo-signature header for ECDSA bundle verification
|
|
8
|
-
* -
|
|
9
|
+
* - fileExtension on launchAsset and assets
|
|
10
|
+
* - extra.expoClient config passthrough
|
|
9
11
|
*/
|
|
10
12
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* Convert a hex SHA-256 hash to base64url encoding.
|
|
14
|
+
* expo-updates verifies downloaded assets by computing SHA-256, base64url-encoding it,
|
|
15
|
+
* and comparing against the manifest `hash` field.
|
|
14
16
|
*/
|
|
15
17
|
function hexToBase64Url(hex) {
|
|
16
18
|
if (hex.length !== 64 || !/^[0-9a-fA-F]+$/.test(hex)) {
|
|
@@ -30,6 +32,10 @@ function hexToBase64Url(hex) {
|
|
|
30
32
|
.replace(/\//g, '_')
|
|
31
33
|
.replace(/=+$/, '');
|
|
32
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Build the Expo Updates v1 manifest object.
|
|
37
|
+
* Matches the format produced by expo/custom-expo-updates-server.
|
|
38
|
+
*/
|
|
33
39
|
export function generateExpoManifest(params) {
|
|
34
40
|
return {
|
|
35
41
|
id: params.updateId,
|
|
@@ -37,7 +43,8 @@ export function generateExpoManifest(params) {
|
|
|
37
43
|
runtimeVersion: params.runtimeVersion,
|
|
38
44
|
launchAsset: {
|
|
39
45
|
hash: hexToBase64Url(params.bundleHash),
|
|
40
|
-
key:
|
|
46
|
+
key: params.bundleKey || params.bundleHash.slice(0, 32),
|
|
47
|
+
fileExtension: ".bundle",
|
|
41
48
|
contentType: "application/javascript",
|
|
42
49
|
url: params.bundleUrl
|
|
43
50
|
},
|
|
@@ -56,45 +63,46 @@ export function generateExpoManifest(params) {
|
|
|
56
63
|
* expo-sfv-version: "0" — Structured Fields version
|
|
57
64
|
* cache-control — Updates must never be stale; client always revalidates
|
|
58
65
|
* expo-signature — ECDSA P-256 + SHA-256 signature over the manifest JSON
|
|
59
|
-
* (base64url-encoded DER). Omitted when no key is configured.
|
|
60
66
|
*/
|
|
61
67
|
export function createExpoHeaders(signature) {
|
|
62
68
|
const headers = {
|
|
63
69
|
"expo-protocol-version": "1",
|
|
64
70
|
"expo-sfv-version": "0",
|
|
65
|
-
"cache-control": "private, max-age=0
|
|
66
|
-
"content-type": "application/json"
|
|
71
|
+
"cache-control": "private, max-age=0",
|
|
67
72
|
};
|
|
68
73
|
if (signature) {
|
|
69
|
-
// EAS format: sig="<base64>", keyid="root", alg="sig-rs256"
|
|
70
|
-
// For ECDSA P-256 we use the simpler bare signature header
|
|
71
74
|
headers["expo-signature"] = `sig="${signature}", keyid="root", alg="ecdsa-p256-sha256"`;
|
|
72
75
|
}
|
|
73
76
|
return headers;
|
|
74
77
|
}
|
|
75
78
|
/**
|
|
76
|
-
* Build the multipart/mixed response body required
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* Layout:
|
|
80
|
-
* --<boundary>
|
|
81
|
-
* Content-Type: application/json
|
|
82
|
-
* expo-signature: sig="...", keyid="root", alg="ecdsa-p256-sha256"
|
|
79
|
+
* Build the multipart/mixed response body required by expo-updates protocol v1.
|
|
80
|
+
* Uses FormData-compatible boundary formatting matching the official Expo server.
|
|
83
81
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
82
|
+
* The expo-updates native client parses this multipart body to extract the manifest
|
|
83
|
+
* from the part named "manifest".
|
|
86
84
|
*/
|
|
87
85
|
export function buildMultipartManifestBody(manifest, signature) {
|
|
88
|
-
const boundary = "
|
|
86
|
+
const boundary = "ota" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
89
87
|
const manifestJson = JSON.stringify(manifest);
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
// Build the manifest part headers exactly as FormData would
|
|
89
|
+
let manifestPart = `--${boundary}\r\n`;
|
|
90
|
+
manifestPart += `Content-Disposition: form-data; name="manifest"\r\n`;
|
|
91
|
+
manifestPart += `Content-Type: application/json; charset=utf-8\r\n`;
|
|
92
92
|
if (signature) {
|
|
93
|
-
|
|
93
|
+
manifestPart += `expo-signature: sig="${signature}", keyid="root", alg="ecdsa-p256-sha256"\r\n`;
|
|
94
94
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
manifestPart += `\r\n`;
|
|
96
|
+
manifestPart += manifestJson;
|
|
97
|
+
manifestPart += `\r\n`;
|
|
98
|
+
// Build the extensions part (included by official Expo server)
|
|
99
|
+
const extensions = { assetRequestHeaders: {} };
|
|
100
|
+
let extensionsPart = `--${boundary}\r\n`;
|
|
101
|
+
extensionsPart += `Content-Disposition: form-data; name="extensions"\r\n`;
|
|
102
|
+
extensionsPart += `Content-Type: application/json\r\n`;
|
|
103
|
+
extensionsPart += `\r\n`;
|
|
104
|
+
extensionsPart += JSON.stringify(extensions);
|
|
105
|
+
extensionsPart += `\r\n`;
|
|
106
|
+
const body = manifestPart + extensionsPart + `--${boundary}--\r\n`;
|
|
107
|
+
return { body, boundary, contentType: `multipart/mixed; boundary=${boundary}` };
|
|
100
108
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,151 +1,289 @@
|
|
|
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.8";
|
|
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
|
-
// ─── 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));
|
|
148
|
-
}
|
|
149
287
|
const CONTENT_TYPES = {
|
|
150
288
|
".js": "application/javascript",
|
|
151
289
|
".hbc": "application/javascript",
|
|
@@ -160,290 +298,432 @@ const CONTENT_TYPES = {
|
|
|
160
298
|
".woff": "font/woff",
|
|
161
299
|
".woff2": "font/woff2",
|
|
162
300
|
".mp4": "video/mp4",
|
|
163
|
-
".webm": "video/webm"
|
|
301
|
+
".webm": "video/webm",
|
|
164
302
|
};
|
|
303
|
+
async function hashFile(filePath) {
|
|
304
|
+
const buffer = fs.readFileSync(filePath);
|
|
305
|
+
return sha256Hex(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
|
|
306
|
+
}
|
|
165
307
|
async function collectAssets(distDir) {
|
|
166
308
|
const entries = [];
|
|
167
309
|
function walk(dir) {
|
|
168
310
|
for (const name of fs.readdirSync(dir)) {
|
|
169
311
|
const full = path.join(dir, name);
|
|
170
|
-
|
|
171
|
-
if (stat.isDirectory()) {
|
|
312
|
+
if (fs.statSync(full).isDirectory()) {
|
|
172
313
|
walk(full);
|
|
173
314
|
}
|
|
174
315
|
else {
|
|
175
316
|
const ext = path.extname(name).toLowerCase();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
317
|
+
entries.push({
|
|
318
|
+
localPath: full,
|
|
319
|
+
key: path.relative(distDir, full).replace(/\\/g, "/"),
|
|
320
|
+
contentType: CONTENT_TYPES[ext] || "application/octet-stream",
|
|
321
|
+
hash: ""
|
|
322
|
+
});
|
|
179
323
|
}
|
|
180
324
|
}
|
|
181
325
|
}
|
|
182
326
|
walk(distDir);
|
|
183
|
-
|
|
184
|
-
await Promise.all(entries.map(async (e) => {
|
|
185
|
-
e.hash = await hashFile(e.localPath);
|
|
186
|
-
}));
|
|
327
|
+
await Promise.all(entries.map(async (e) => { e.hash = await hashFile(e.localPath); }));
|
|
187
328
|
return entries;
|
|
188
329
|
}
|
|
189
|
-
// ─── Detect the JS bundle within the Expo export output ──────────────────────
|
|
190
330
|
function findBundle(distDir, platform) {
|
|
191
|
-
// Expo SDK 50+ writes: dist/_expo/static/js/<platform>/<hash>.js (or .hbc)
|
|
192
331
|
const expoStaticJs = path.join(distDir, "_expo", "static", "js", platform);
|
|
193
332
|
if (fs.existsSync(expoStaticJs)) {
|
|
194
333
|
const files = fs.readdirSync(expoStaticJs).filter(f => f.endsWith(".js") || f.endsWith(".hbc"));
|
|
195
334
|
if (files.length)
|
|
196
335
|
return path.join(expoStaticJs, files[0]);
|
|
197
336
|
}
|
|
198
|
-
// Fallback: older SDK flat layout
|
|
199
337
|
const flatJs = path.join(distDir, `index.${platform}.js`);
|
|
200
338
|
if (fs.existsSync(flatJs))
|
|
201
339
|
return flatJs;
|
|
202
340
|
const flatHbc = path.join(distDir, `index.${platform}.hbc`);
|
|
203
341
|
if (fs.existsSync(flatHbc))
|
|
204
342
|
return flatHbc;
|
|
205
|
-
// Any .js or .hbc at root
|
|
206
343
|
const rootJs = fs.readdirSync(distDir).find(f => f.endsWith(".js") || f.endsWith(".hbc"));
|
|
207
344
|
if (rootJs)
|
|
208
345
|
return path.join(distDir, rootJs);
|
|
209
346
|
return null;
|
|
210
347
|
}
|
|
211
|
-
// ───
|
|
348
|
+
// ─── Auth token resolution ────────────────────────────────────────────────────
|
|
349
|
+
function resolveToken() {
|
|
350
|
+
// 1. Global config
|
|
351
|
+
const cfg = loadGlobalConfig();
|
|
352
|
+
if (cfg?.token)
|
|
353
|
+
return cfg.token;
|
|
354
|
+
// 2. Env var fallback
|
|
355
|
+
return process.env.EDGE_OTA_TOKEN || null;
|
|
356
|
+
}
|
|
357
|
+
// ─── Program ─────────────────────────────────────────────────────────────────
|
|
212
358
|
const program = new Command();
|
|
213
359
|
program
|
|
214
360
|
.name("edge-ota")
|
|
215
|
-
.description("Zero-SDK
|
|
216
|
-
.version(
|
|
217
|
-
//
|
|
361
|
+
.description("Zero-SDK OTA update platform for Expo — by Renbo Studios")
|
|
362
|
+
.version(packageVersion);
|
|
363
|
+
// Show help + auth status when called with no arguments
|
|
364
|
+
program.action(() => {
|
|
365
|
+
printBanner();
|
|
366
|
+
const cfg = loadGlobalConfig();
|
|
367
|
+
if (cfg?.email) {
|
|
368
|
+
console.log(` ${c.green}●${c.reset} Logged in as ${c.bold}${cfg.email}${c.reset} ${c.dim}(${cfg.serverUrl})${c.reset}`);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
console.log(` ${c.dim}○ Not logged in — run ${c.reset}${c.cyan}edge-ota login${c.reset}`);
|
|
372
|
+
}
|
|
373
|
+
console.log();
|
|
374
|
+
console.log(` ${c.bold}Commands${c.reset}`);
|
|
375
|
+
const cmds = [
|
|
376
|
+
["login", "Authenticate and save credentials globally"],
|
|
377
|
+
["logout", "Remove stored credentials"],
|
|
378
|
+
["init", "Register project, generate signing keys, configure app.json"],
|
|
379
|
+
["push", "Export bundle and publish an OTA update"],
|
|
380
|
+
["status", "List recent deployments for this project"],
|
|
381
|
+
["keygen", "Generate a new ECDSA key pair (prints to stdout)"],
|
|
382
|
+
];
|
|
383
|
+
for (const [cmd, desc] of cmds) {
|
|
384
|
+
console.log(` ${c.cyan}${cmd.padEnd(10)}${c.reset} ${c.dim}${desc}${c.reset}`);
|
|
385
|
+
}
|
|
386
|
+
console.log();
|
|
387
|
+
console.log(` Run ${c.cyan}edge-ota <command> --help${c.reset} for command-specific options.`);
|
|
388
|
+
console.log();
|
|
389
|
+
});
|
|
390
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
391
|
+
// edge-ota login
|
|
392
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
393
|
+
program
|
|
394
|
+
.command("login")
|
|
395
|
+
.description("Authenticate with your EdgeOTA account")
|
|
396
|
+
.option("-s, --server <url>", "EdgeOTA server URL", "https://api.edge-ota.renbo.site")
|
|
397
|
+
.action(async (options) => {
|
|
398
|
+
printBanner();
|
|
399
|
+
console.log(` ${c.bold}Sign in to EdgeOTA${c.reset}\n`);
|
|
400
|
+
const serverUrl = options.server.replace(/\/$/, "");
|
|
401
|
+
const email = await ask(` ${c.dim}email:${c.reset} `);
|
|
402
|
+
const password = await askSecret(` ${c.dim}password:${c.reset} `);
|
|
403
|
+
spin.start("authenticating");
|
|
404
|
+
try {
|
|
405
|
+
const res = await fetch(`${serverUrl}/api/auth/login`, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: { "Content-Type": "application/json" },
|
|
408
|
+
body: JSON.stringify({ email, password }),
|
|
409
|
+
});
|
|
410
|
+
if (!res.ok) {
|
|
411
|
+
const text = await res.text();
|
|
412
|
+
spin.fail(`Login failed: ${text || res.statusText}`);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
const data = await res.json();
|
|
416
|
+
saveGlobalConfig({ token: data.token, email: data.email, serverUrl });
|
|
417
|
+
spin.stop();
|
|
418
|
+
const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
|
|
419
|
+
console.log(sep);
|
|
420
|
+
console.log(` ${c.green}✓${c.reset} Logged in as ${c.bold}${data.email}${c.reset}`);
|
|
421
|
+
console.log(` ${c.dim}credentials saved to ${GLOBAL_CONFIG_FILE}${c.reset}`);
|
|
422
|
+
console.log(sep + "\n");
|
|
423
|
+
}
|
|
424
|
+
catch (e) {
|
|
425
|
+
spin.fail(`Connection failed: ${e.message}`);
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
430
|
+
// edge-ota logout
|
|
431
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
432
|
+
program
|
|
433
|
+
.command("logout")
|
|
434
|
+
.description("Remove stored credentials")
|
|
435
|
+
.action(() => {
|
|
436
|
+
printBanner();
|
|
437
|
+
const cfg = loadGlobalConfig();
|
|
438
|
+
clearGlobalConfig();
|
|
439
|
+
if (cfg?.email) {
|
|
440
|
+
console.log(` ${c.green}✓${c.reset} Logged out from ${c.bold}${cfg.email}${c.reset}\n`);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
console.log(` ${c.dim}Already logged out.${c.reset}\n`);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
218
447
|
// edge-ota init
|
|
219
|
-
//
|
|
448
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
220
449
|
program
|
|
221
450
|
.command("init")
|
|
222
|
-
.description("
|
|
223
|
-
.option("-s, --server <url>", "EdgeOTA server URL")
|
|
224
|
-
.option("--project <id>", "EdgeOTA Project ID (optional)")
|
|
451
|
+
.description("Register project on EdgeOTA, generate signing keys, configure app.json")
|
|
452
|
+
.option("-s, --server <url>", "EdgeOTA server URL (overrides logged-in server)")
|
|
225
453
|
.action(async (options) => {
|
|
226
454
|
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;
|
|
455
|
+
const token = resolveToken();
|
|
456
|
+
if (!token) {
|
|
457
|
+
console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.\n`);
|
|
458
|
+
process.exit(1);
|
|
459
|
+
}
|
|
460
|
+
const globalCfg = loadGlobalConfig();
|
|
461
|
+
const serverUrl = (options.server || globalCfg?.serverUrl || "https://api.edge-ota.renbo.site").replace(/\/$/, "");
|
|
245
462
|
const cwd = process.cwd();
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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;
|
|
476
|
+
try {
|
|
477
|
+
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 }),
|
|
481
|
+
});
|
|
482
|
+
if (!res.ok) {
|
|
483
|
+
const text = await res.text();
|
|
484
|
+
spin.fail(`Failed to register project: ${text}`);
|
|
485
|
+
process.exit(1);
|
|
254
486
|
}
|
|
487
|
+
const data = await res.json();
|
|
488
|
+
projectId = data.id;
|
|
489
|
+
spin.stop("project registered");
|
|
255
490
|
}
|
|
256
|
-
|
|
257
|
-
|
|
491
|
+
catch (e) {
|
|
492
|
+
spin.fail(`Connection error: ${e.message}`);
|
|
493
|
+
process.exit(1);
|
|
258
494
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const sep = `${
|
|
264
|
-
console.log(`\n
|
|
265
|
-
console.log(
|
|
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}`);
|
|
495
|
+
// 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}`);
|
|
269
502
|
console.log(sep);
|
|
270
|
-
console.log(
|
|
271
|
-
console.log(
|
|
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`);
|
|
272
508
|
});
|
|
273
|
-
//
|
|
509
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
274
510
|
// edge-ota push
|
|
275
|
-
//
|
|
511
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
276
512
|
program
|
|
277
513
|
.command("push")
|
|
278
|
-
.description("Export
|
|
514
|
+
.description("Export your Expo bundle and publish an OTA update")
|
|
279
515
|
.option("-c, --channel <channel>", "Deployment channel", "production")
|
|
280
|
-
.option("-r, --runtime <version>", "React Native runtime version (semver)", "1.0.0")
|
|
281
516
|
.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
517
|
.option("--skip-export", "Skip expo export (use existing ./dist directory)")
|
|
285
518
|
.option("--dry-run", "Build and sign the payload but do NOT upload")
|
|
286
519
|
.action(async (options) => {
|
|
287
520
|
const cwd = process.cwd();
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
// Resolve token — CLI flag > env var
|
|
291
|
-
const token = options.token || process.env.EDGE_OTA_TOKEN;
|
|
521
|
+
const token = resolveToken();
|
|
522
|
+
printBanner();
|
|
292
523
|
if (!token) {
|
|
293
|
-
console.error(
|
|
294
|
-
console.error(`
|
|
524
|
+
console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.`);
|
|
525
|
+
console.error(` Or set ${c.dim}EDGE_OTA_TOKEN${c.reset} environment variable.\n`);
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
528
|
+
// Auto-detect from app.json
|
|
529
|
+
const appCfg = readAppJson(cwd);
|
|
530
|
+
const { serverUrl, projectId, runtimeVersion } = appCfg;
|
|
531
|
+
// Load private signing key
|
|
532
|
+
const privateKey = projectId ? loadPrivateKey(projectId) : null;
|
|
533
|
+
if (!privateKey) {
|
|
534
|
+
console.error(` ${c.red}✗${c.reset} No signing key found for this project.`);
|
|
535
|
+
if (projectId) {
|
|
536
|
+
console.error(` Expected at: ${c.dim}${path.join(GLOBAL_KEYS_DIR, `${projectId}.key`)}${c.reset}`);
|
|
537
|
+
}
|
|
538
|
+
console.error(` Run ${c.cyan}edge-ota init${c.reset} to set up code signing.\n`);
|
|
295
539
|
process.exit(1);
|
|
296
540
|
}
|
|
297
|
-
// Resolve project ID — CLI flag > config file
|
|
298
|
-
const projectId = options.project || config.projectId;
|
|
299
|
-
// Build the correct upload URL
|
|
300
541
|
const uploadUrl = projectId
|
|
301
|
-
? `${
|
|
302
|
-
: `${
|
|
303
|
-
// ── Step 1: Build ────────────────────────────────────────────────────────
|
|
304
|
-
printBanner();
|
|
542
|
+
? `${serverUrl}/api/projects/${projectId}/updates`
|
|
543
|
+
: `${serverUrl}/api/updates`;
|
|
305
544
|
const distDir = path.resolve(cwd, "dist");
|
|
545
|
+
// ── Step 1: Expo export ──────────────────────────────────────────────────
|
|
306
546
|
if (!options.skipExport) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
547
|
+
spin.start("running expo export");
|
|
548
|
+
await new Promise((resolve, reject) => {
|
|
549
|
+
const proc = spawn("npx", ["expo", "export"], {
|
|
550
|
+
cwd,
|
|
551
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
552
|
+
shell: process.platform === "win32",
|
|
553
|
+
});
|
|
554
|
+
// Filter stdout: only show platform bundle lines
|
|
555
|
+
proc.stdout?.on("data", (chunk) => {
|
|
556
|
+
const text = chunk.toString();
|
|
557
|
+
for (const line of text.split("\n")) {
|
|
558
|
+
const t = line.trim();
|
|
559
|
+
// Show platform bundle summary lines only
|
|
560
|
+
if (t.startsWith("_expo/static/js/") || t.match(/\.(hbc|js)\s+\(\d/)) {
|
|
561
|
+
spin.stop();
|
|
562
|
+
console.log(` ${c.dim}${t}${c.reset}`);
|
|
563
|
+
spin.start("running expo export");
|
|
564
|
+
}
|
|
565
|
+
// Update spinner with bundling progress
|
|
566
|
+
if (t.match(/Bundled \d+ms/)) {
|
|
567
|
+
spin.update("expo export — " + t.replace("Bundled", "").trim());
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
// Suppress stderr noise but capture real errors
|
|
572
|
+
let stderrBuf = "";
|
|
573
|
+
proc.stderr?.on("data", (chunk) => { stderrBuf += chunk.toString(); });
|
|
574
|
+
proc.on("close", code => {
|
|
575
|
+
if (code === 0) {
|
|
576
|
+
spin.stop("expo export complete");
|
|
577
|
+
resolve();
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
spin.fail("expo export failed");
|
|
581
|
+
// Print last few lines of stderr for context
|
|
582
|
+
const lines = stderrBuf.split("\n").filter(Boolean);
|
|
583
|
+
for (const l of lines.slice(-10))
|
|
584
|
+
console.error(` ${c.dim}${l}${c.reset}`);
|
|
585
|
+
reject(new Error(`expo export exited with code ${code}`));
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
}).catch(() => process.exit(1));
|
|
315
589
|
}
|
|
316
590
|
else {
|
|
317
|
-
console.log(` ${
|
|
591
|
+
console.log(` ${c.dim}skip-export — using existing ./dist${c.reset}`);
|
|
318
592
|
}
|
|
319
593
|
if (!fs.existsSync(distDir)) {
|
|
320
|
-
console.error(` ${
|
|
594
|
+
console.error(` ${c.red}✗${c.reset} ./dist not found after export\n`);
|
|
321
595
|
process.exit(1);
|
|
322
596
|
}
|
|
323
|
-
|
|
597
|
+
// ── Step 2: Collect assets ───────────────────────────────────────────────
|
|
598
|
+
spin.start("collecting assets");
|
|
324
599
|
const assets = await collectAssets(distDir);
|
|
325
|
-
|
|
600
|
+
spin.stop(`found ${assets.length} asset(s)`);
|
|
326
601
|
const platforms = options.platform === "all" ? ["ios", "android"] : [options.platform];
|
|
327
|
-
const sep = `${
|
|
602
|
+
const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
|
|
603
|
+
// ── Step 3: Per-platform upload ──────────────────────────────────────────
|
|
328
604
|
for (const platform of platforms) {
|
|
329
605
|
console.log(`\n${sep}`);
|
|
330
|
-
console.log(` ${
|
|
606
|
+
console.log(` ${c.bold}${platform}${c.reset}`);
|
|
331
607
|
console.log(sep);
|
|
332
608
|
const bundlePath = findBundle(distDir, platform);
|
|
333
609
|
if (!bundlePath) {
|
|
334
|
-
console.warn(` ${
|
|
610
|
+
console.warn(` ${c.yellow}⚠${c.reset} no bundle found for ${platform}, skipping`);
|
|
335
611
|
continue;
|
|
336
612
|
}
|
|
337
613
|
const bundleHash = await hashFile(bundlePath);
|
|
614
|
+
const bundleSize = (fs.statSync(bundlePath).size / 1024 / 1024).toFixed(2);
|
|
615
|
+
console.log(` bundle ${c.dim}${bundleHash.slice(0, 16)}… ${bundleSize} MB${c.reset}`);
|
|
338
616
|
const payloadObj = {
|
|
339
617
|
channel: options.channel,
|
|
340
|
-
runtimeVersion
|
|
618
|
+
runtimeVersion,
|
|
341
619
|
platform,
|
|
342
620
|
bundleHash,
|
|
343
621
|
timestamp: Date.now(),
|
|
344
622
|
assetCount: assets.length,
|
|
345
|
-
publicKey:
|
|
623
|
+
publicKey: appCfg.publicKey,
|
|
346
624
|
};
|
|
347
625
|
const payloadStr = JSON.stringify(payloadObj);
|
|
348
|
-
|
|
626
|
+
spin.start("signing");
|
|
349
627
|
const signature = await signPayload(payloadStr, privateKey);
|
|
350
|
-
|
|
628
|
+
spin.stop(`signed ${c.dim}${signature.slice(0, 20)}…${c.reset}`);
|
|
351
629
|
if (options.dryRun) {
|
|
352
|
-
console.log(` ${
|
|
353
|
-
console.log(" payload ", payloadObj);
|
|
630
|
+
console.log(` ${c.yellow}dry-run${c.reset} upload skipped`);
|
|
354
631
|
continue;
|
|
355
632
|
}
|
|
356
|
-
|
|
633
|
+
spin.start("uploading");
|
|
357
634
|
const bundleBuffer = fs.readFileSync(bundlePath);
|
|
358
635
|
const form = new FormData();
|
|
359
636
|
form.append("bundle", new Blob([bundleBuffer], { type: "application/javascript" }), `bundle-${platform}.hbc`);
|
|
360
637
|
form.append("payload", payloadStr);
|
|
361
638
|
form.append("signature", signature);
|
|
362
639
|
form.append("platform", platform);
|
|
363
|
-
console.log(` uploading ${colors.dim}${uploadUrl}${colors.reset}`);
|
|
364
640
|
const response = await fetch(uploadUrl, {
|
|
365
641
|
method: "POST",
|
|
366
642
|
headers: {
|
|
367
|
-
|
|
643
|
+
Authorization: `Bearer ${token}`,
|
|
368
644
|
...(projectId ? { "X-Project-Id": projectId } : {})
|
|
369
645
|
},
|
|
370
|
-
body: form
|
|
646
|
+
body: form,
|
|
647
|
+
}).catch(e => {
|
|
648
|
+
spin.fail(`Upload failed: ${e.message}`);
|
|
649
|
+
process.exit(1);
|
|
371
650
|
});
|
|
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 {
|
|
651
|
+
if (!response.ok) {
|
|
382
652
|
const text = await response.text();
|
|
383
|
-
|
|
653
|
+
spin.fail(`Upload failed (HTTP ${response.status}): ${text}`);
|
|
384
654
|
process.exit(1);
|
|
385
655
|
}
|
|
656
|
+
const body = await response.json().catch(() => ({}));
|
|
657
|
+
spin.stop("uploaded");
|
|
658
|
+
console.log(` ${c.green}✓${c.reset} deployed`);
|
|
659
|
+
console.log(` id ${c.dim}${body.updateId || "—"}${c.reset}`);
|
|
660
|
+
console.log(` channel ${options.channel}`);
|
|
661
|
+
console.log(` runtime ${runtimeVersion}`);
|
|
386
662
|
}
|
|
387
|
-
console.log(`\n${
|
|
388
|
-
console.log(` ${
|
|
389
|
-
console.log(`${
|
|
663
|
+
console.log(`\n${sep}`);
|
|
664
|
+
console.log(` ${c.green}✓${c.reset} ${c.bold}done${c.reset} ${c.dim}update will be applied on next OTA sync${c.reset}`);
|
|
665
|
+
console.log(`${sep}\n`);
|
|
390
666
|
});
|
|
391
|
-
//
|
|
667
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
392
668
|
// edge-ota status
|
|
393
|
-
//
|
|
669
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
394
670
|
program
|
|
395
671
|
.command("status")
|
|
396
|
-
.description("List recent
|
|
397
|
-
.option("-t, --token <token>", "Auth token (or set EDGE_OTA_TOKEN env var)")
|
|
672
|
+
.description("List recent deployments for this project")
|
|
398
673
|
.option("-n, --limit <n>", "Number of releases to show", "10")
|
|
399
|
-
.option("--project <id>", "Project ID (optional)")
|
|
400
674
|
.action(async (options) => {
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
const token = options.token || process.env.EDGE_OTA_TOKEN;
|
|
404
|
-
const projectId = options.project || config.projectId;
|
|
675
|
+
printBanner();
|
|
676
|
+
const token = resolveToken();
|
|
405
677
|
if (!token) {
|
|
406
|
-
console.error(` ${
|
|
678
|
+
console.error(` ${c.red}✗${c.reset} Not logged in. Run ${c.cyan}edge-ota login${c.reset} first.\n`);
|
|
407
679
|
process.exit(1);
|
|
408
680
|
}
|
|
681
|
+
const cwd = process.cwd();
|
|
682
|
+
const appCfg = readAppJson(cwd);
|
|
683
|
+
const { serverUrl, projectId } = appCfg;
|
|
409
684
|
const headers = { Authorization: `Bearer ${token}` };
|
|
410
685
|
if (projectId)
|
|
411
686
|
headers["x-project-id"] = projectId;
|
|
412
|
-
|
|
687
|
+
spin.start("fetching releases");
|
|
688
|
+
const res = await fetch(`${serverUrl}/api/releases`, { headers }).catch(e => {
|
|
689
|
+
spin.fail(`Connection failed: ${e.message}`);
|
|
690
|
+
process.exit(1);
|
|
691
|
+
});
|
|
413
692
|
if (!res.ok) {
|
|
414
|
-
|
|
693
|
+
spin.fail(`Failed to fetch releases (HTTP ${res.status})`);
|
|
415
694
|
process.exit(1);
|
|
416
695
|
}
|
|
417
|
-
const releases =
|
|
696
|
+
const releases = await res.json();
|
|
697
|
+
spin.stop();
|
|
418
698
|
if (!releases.length) {
|
|
419
|
-
console.log(` ${
|
|
699
|
+
console.log(` ${c.dim}No releases found.${c.reset}\n`);
|
|
420
700
|
return;
|
|
421
701
|
}
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
702
|
+
const limit = parseInt(options.limit);
|
|
703
|
+
const rows = releases.slice(0, limit).map((r) => ({
|
|
704
|
+
ID: r.id?.slice(0, 8) ?? "—",
|
|
705
|
+
Channel: r.channel ?? "—",
|
|
706
|
+
Runtime: r.runtime ?? "—",
|
|
707
|
+
Platform: r.platform ?? "all",
|
|
708
|
+
Created: r.created_at ? new Date(r.created_at).toLocaleString() : "—",
|
|
429
709
|
}));
|
|
430
710
|
console.table(rows);
|
|
431
711
|
});
|
|
432
|
-
//
|
|
712
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
433
713
|
// edge-ota keygen
|
|
434
|
-
//
|
|
714
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
435
715
|
program
|
|
436
716
|
.command("keygen")
|
|
437
|
-
.description("Generate a fresh ECDSA key pair (prints to stdout, does not write files)")
|
|
717
|
+
.description("Generate a fresh ECDSA P-256 key pair (prints to stdout, does not write files)")
|
|
438
718
|
.action(async () => {
|
|
439
719
|
const keys = await generateECDSAKeyPair();
|
|
440
|
-
const sep = `${
|
|
720
|
+
const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
|
|
441
721
|
console.log(`\n${sep}`);
|
|
442
|
-
console.log(` ${
|
|
722
|
+
console.log(` ${c.bold}private key${c.reset} ${c.dim}keep secret — never commit${c.reset}`);
|
|
443
723
|
console.log(sep);
|
|
444
724
|
console.log(keys.privateKey);
|
|
445
725
|
console.log(`\n${sep}`);
|
|
446
|
-
console.log(` ${
|
|
726
|
+
console.log(` ${c.bold}public key${c.reset} ${c.dim}paste into dashboard → Settings → General${c.reset}`);
|
|
447
727
|
console.log(sep);
|
|
448
728
|
console.log(keys.publicKey);
|
|
449
729
|
});
|
package/package.json
CHANGED