@renbostudios/edge-ota 0.1.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.
- package/README.md +119 -0
- package/dist/core/crypto.js +87 -0
- package/dist/core/index.js +2 -0
- package/dist/core/manifest.js +78 -0
- package/dist/index.js +328 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# `@renbostudios/edge-ota`
|
|
2
|
+
|
|
3
|
+
> Zero-SDK, self-hostable OTA update CLI for Expo apps.
|
|
4
|
+
> Push cryptographically signed JS bundles to any EdgeOTA server — no Expo account required.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install -g @renbostudios/edge-ota
|
|
10
|
+
# or use without installing:
|
|
11
|
+
npx @renbostudios/edge-ota <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
|
+
```
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
### `edge-ota init [options]`
|
|
32
|
+
|
|
33
|
+
Generates an ECDSA P-256 key pair, writes `edge-ota.config.json` and `.edge-ota.private.key`, and auto-adds the private key to `.gitignore`.
|
|
34
|
+
|
|
35
|
+
| Option | Default | Description |
|
|
36
|
+
|--------|---------|-------------|
|
|
37
|
+
| `-s, --server <url>` | `http://localhost:3000` | Your EdgeOTA server URL |
|
|
38
|
+
|
|
39
|
+
**Output:**
|
|
40
|
+
- `edge-ota.config.json` — server URL + public key (safe to commit)
|
|
41
|
+
- `.edge-ota.private.key` — private signing key (**never commit this**)
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
### `edge-ota push [options]`
|
|
46
|
+
|
|
47
|
+
Runs `expo export`, hashes every asset with SHA-256, signs the payload with your private key, then uploads the bundle to your server.
|
|
48
|
+
|
|
49
|
+
| Option | Default | Description |
|
|
50
|
+
|--------|---------|-------------|
|
|
51
|
+
| `-c, --channel <name>` | `production` | Deployment channel |
|
|
52
|
+
| `-r, --runtime <version>` | `1.0.0` | Native runtime version (must match `runtimeVersion` in app.json) |
|
|
53
|
+
| `-p, --platform <platform>` | `all` | `ios`, `android`, or `all` |
|
|
54
|
+
| `--skip-export` | — | Skip `expo export` — use existing `./dist` |
|
|
55
|
+
| `--dry-run` | — | Sign the payload but don't upload |
|
|
56
|
+
|
|
57
|
+
**Example — CI/CD:**
|
|
58
|
+
```bash
|
|
59
|
+
edge-ota push \
|
|
60
|
+
--channel production \
|
|
61
|
+
--runtime 1.2.0 \
|
|
62
|
+
--platform all
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
### `edge-ota status [options]`
|
|
68
|
+
|
|
69
|
+
Lists recent releases from the server as a table.
|
|
70
|
+
|
|
71
|
+
| Option | Default | Description |
|
|
72
|
+
|--------|---------|-------------|
|
|
73
|
+
| `-t, --token <token>` | `$EDGE_OTA_TOKEN` | Auth token from the dashboard |
|
|
74
|
+
| `-n, --limit <n>` | `10` | Number of releases to show |
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
export EDGE_OTA_TOKEN="your-dashboard-token"
|
|
78
|
+
edge-ota status
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### `edge-ota keygen`
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
edge-ota keygen
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## How it works
|
|
94
|
+
|
|
95
|
+
1. **Build** — `expo export` produces a JS bundle + assets in `./dist/`
|
|
96
|
+
2. **Hash** — every file gets a SHA-256 content hash (enables deduplication on the server)
|
|
97
|
+
3. **Sign** — the upload payload (`{ channel, runtimeVersion, bundleHash, ... }`) is signed with your ECDSA P-256 private key
|
|
98
|
+
4. **Upload** — `POST /api/updates` with `multipart/form-data` containing the bundle zip, payload JSON, and signature
|
|
99
|
+
5. **Serve** — when your app checks for updates, the server verifies the stored signature and returns a signed manifest
|
|
100
|
+
6. **Verify** — the Expo SDK verifies the manifest signature and bundle hash before swapping the active bundle
|
|
101
|
+
|
|
102
|
+
## Expo App Setup
|
|
103
|
+
|
|
104
|
+
Add to your `app.json`:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"expo": {
|
|
109
|
+
"updates": {
|
|
110
|
+
"url": "https://your-edge-ota-server.com/api/updates",
|
|
111
|
+
"runtimeVersion": { "policy": "nativeVersion" }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EdgeOTA Core - Cryptographic Utilities
|
|
3
|
+
*
|
|
4
|
+
* Implements ECDSA P-256 + SHA-256 signing and verification,
|
|
5
|
+
* compatible with both Node.js (via globalThis.crypto.subtle) and
|
|
6
|
+
* the Cloudflare Workers runtime (same Web Crypto API).
|
|
7
|
+
*
|
|
8
|
+
* Key format: PKCS#8 private key / SPKI public key — PEM-encoded.
|
|
9
|
+
* Signature format: raw DER bytes → base64 string (NOT base64url,
|
|
10
|
+
* to stay compatible with the existing expo-updates SDK verification).
|
|
11
|
+
*/
|
|
12
|
+
/** Generate a fresh ECDSA P-256 key pair encoded as PEM. */
|
|
13
|
+
export async function generateECDSAKeyPair() {
|
|
14
|
+
const keyPair = await globalThis.crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
|
|
15
|
+
const privateKeyDer = await globalThis.crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
|
|
16
|
+
const publicKeyDer = await globalThis.crypto.subtle.exportKey("spki", keyPair.publicKey);
|
|
17
|
+
return {
|
|
18
|
+
privateKey: derToPem(privateKeyDer, "PRIVATE KEY"),
|
|
19
|
+
publicKey: derToPem(publicKeyDer, "PUBLIC KEY")
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Sign an arbitrary string payload with a PEM-encoded ECDSA P-256 private key.
|
|
24
|
+
* Returns the raw signature bytes encoded as base64.
|
|
25
|
+
*/
|
|
26
|
+
export async function signPayload(payload, privateKeyPem) {
|
|
27
|
+
const privateKeyDer = pemToDer(privateKeyPem, "PRIVATE KEY");
|
|
28
|
+
const privateKey = await globalThis.crypto.subtle.importKey("pkcs8", privateKeyDer, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
|
|
29
|
+
const data = new TextEncoder().encode(payload);
|
|
30
|
+
const sigBuffer = await globalThis.crypto.subtle.sign({ name: "ECDSA", hash: { name: "SHA-256" } }, privateKey, data);
|
|
31
|
+
return arrayBufferToBase64(sigBuffer);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Verify a base64-encoded ECDSA P-256 signature against a payload string
|
|
35
|
+
* using a PEM-encoded public key.
|
|
36
|
+
*/
|
|
37
|
+
export async function verifyPayload(payload, signatureBase64, publicKeyPem) {
|
|
38
|
+
try {
|
|
39
|
+
const publicKeyDer = pemToDer(publicKeyPem, "PUBLIC KEY");
|
|
40
|
+
const publicKey = await globalThis.crypto.subtle.importKey("spki", publicKeyDer, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
41
|
+
const data = new TextEncoder().encode(payload);
|
|
42
|
+
const signature = base64ToArrayBuffer(signatureBase64);
|
|
43
|
+
return await globalThis.crypto.subtle.verify({ name: "ECDSA", hash: { name: "SHA-256" } }, publicKey, signature, data);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* SHA-256 hash a buffer and return the hex string.
|
|
51
|
+
* Used to produce the per-asset `hash` field in the Expo manifest.
|
|
52
|
+
*/
|
|
53
|
+
export async function sha256Hex(buffer) {
|
|
54
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer);
|
|
55
|
+
return Array.from(new Uint8Array(digest))
|
|
56
|
+
.map(b => b.toString(16).padStart(2, "0"))
|
|
57
|
+
.join("");
|
|
58
|
+
}
|
|
59
|
+
// ─── Internal PEM / DER utilities ────────────────────────────────────────────
|
|
60
|
+
function derToPem(der, label) {
|
|
61
|
+
const base64 = arrayBufferToBase64(der);
|
|
62
|
+
const lines = base64.match(/.{1,64}/g) || [];
|
|
63
|
+
return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----`;
|
|
64
|
+
}
|
|
65
|
+
function pemToDer(pem, label) {
|
|
66
|
+
const cleanPem = pem
|
|
67
|
+
.replace(`-----BEGIN ${label}-----`, "")
|
|
68
|
+
.replace(`-----END ${label}-----`, "")
|
|
69
|
+
.replace(/\s+/g, "");
|
|
70
|
+
return base64ToArrayBuffer(cleanPem);
|
|
71
|
+
}
|
|
72
|
+
function arrayBufferToBase64(buffer) {
|
|
73
|
+
const bytes = new Uint8Array(buffer);
|
|
74
|
+
let binary = "";
|
|
75
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
76
|
+
binary += String.fromCharCode(bytes[i]);
|
|
77
|
+
}
|
|
78
|
+
return globalThis.btoa(binary);
|
|
79
|
+
}
|
|
80
|
+
function base64ToArrayBuffer(base64) {
|
|
81
|
+
const binary = globalThis.atob(base64);
|
|
82
|
+
const bytes = new Uint8Array(binary.length);
|
|
83
|
+
for (let i = 0; i < binary.length; i++) {
|
|
84
|
+
bytes[i] = binary.charCodeAt(i);
|
|
85
|
+
}
|
|
86
|
+
return bytes.buffer;
|
|
87
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EdgeOTA Core - Expo Updates Protocol v1 Manifest Engine
|
|
3
|
+
*
|
|
4
|
+
* Implements the exact wire format consumed by expo-updates SDK:
|
|
5
|
+
* - EAS Update-compatible manifest structure
|
|
6
|
+
* - Multipart/mixed response body with proper boundary
|
|
7
|
+
* - expo-signature header for ECDSA bundle verification
|
|
8
|
+
* - Structured extension (extra) metadata passthrough
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Build the Expo Updates v1 manifest object.
|
|
12
|
+
* The client SDK deserialises this from the `manifest` part of the
|
|
13
|
+
* multipart response (or directly from the response body in non-multipart mode).
|
|
14
|
+
*/
|
|
15
|
+
export function generateExpoManifest(params) {
|
|
16
|
+
return {
|
|
17
|
+
id: params.updateId,
|
|
18
|
+
createdAt: params.createdAt,
|
|
19
|
+
runtimeVersion: params.runtimeVersion,
|
|
20
|
+
launchAsset: {
|
|
21
|
+
hash: params.bundleHash,
|
|
22
|
+
key: "bundle",
|
|
23
|
+
contentType: "application/javascript",
|
|
24
|
+
url: params.bundleUrl
|
|
25
|
+
},
|
|
26
|
+
assets: params.assets,
|
|
27
|
+
metadata: params.metadata ?? {},
|
|
28
|
+
extra: params.extra ?? {}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the response headers required by the Expo Updates protocol.
|
|
33
|
+
*
|
|
34
|
+
* expo-protocol-version: "1" — Signals v1 manifest format
|
|
35
|
+
* expo-sfv-version: "0" — Structured Fields version
|
|
36
|
+
* cache-control — Updates must never be stale; client always revalidates
|
|
37
|
+
* expo-signature — ECDSA P-256 + SHA-256 signature over the manifest JSON
|
|
38
|
+
* (base64url-encoded DER). Omitted when no key is configured.
|
|
39
|
+
*/
|
|
40
|
+
export function createExpoHeaders(signature) {
|
|
41
|
+
const headers = {
|
|
42
|
+
"expo-protocol-version": "1",
|
|
43
|
+
"expo-sfv-version": "0",
|
|
44
|
+
"cache-control": "private, max-age=0, must-revalidate",
|
|
45
|
+
"content-type": "application/json"
|
|
46
|
+
};
|
|
47
|
+
if (signature) {
|
|
48
|
+
// EAS format: sig="<base64>", keyid="root", alg="sig-rs256"
|
|
49
|
+
// For ECDSA P-256 we use the simpler bare signature header
|
|
50
|
+
headers["expo-signature"] = `sig="${signature}", keyid="root", alg="ecdsa-p256-sha256"`;
|
|
51
|
+
}
|
|
52
|
+
return headers;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build the multipart/mixed response body required when the client sends
|
|
56
|
+
* `Accept: multipart/mixed` (expo-updates >=0.18).
|
|
57
|
+
*
|
|
58
|
+
* Layout:
|
|
59
|
+
* --<boundary>
|
|
60
|
+
* Content-Type: application/json
|
|
61
|
+
* expo-signature: sig="...", keyid="root", alg="ecdsa-p256-sha256"
|
|
62
|
+
*
|
|
63
|
+
* <manifest JSON>
|
|
64
|
+
* --<boundary>--
|
|
65
|
+
*/
|
|
66
|
+
export function buildMultipartManifestBody(manifest, signature) {
|
|
67
|
+
const boundary = "expo-update-boundary-" + Math.random().toString(36).slice(2, 10);
|
|
68
|
+
const manifestJson = JSON.stringify(manifest);
|
|
69
|
+
let partHeaders = "Content-Type: application/json\r\n";
|
|
70
|
+
if (signature) {
|
|
71
|
+
partHeaders += `expo-signature: sig="${signature}", keyid="root", alg="ecdsa-p256-sha256"\r\n`;
|
|
72
|
+
}
|
|
73
|
+
const body = `--${boundary}\r\n` +
|
|
74
|
+
`${partHeaders}\r\n` +
|
|
75
|
+
`${manifestJson}\r\n` +
|
|
76
|
+
`--${boundary}--\r\n`;
|
|
77
|
+
return { body, boundary };
|
|
78
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* edge-ota CLI
|
|
4
|
+
*
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
import { Command } from "commander";
|
|
28
|
+
import { execSync } from "child_process";
|
|
29
|
+
import fs from "fs";
|
|
30
|
+
import path from "path";
|
|
31
|
+
import { createWriteStream } from "fs";
|
|
32
|
+
import archiver from "archiver";
|
|
33
|
+
import { generateECDSAKeyPair, signPayload, sha256Hex } from "./core/index.js";
|
|
34
|
+
function loadConfig(cwd) {
|
|
35
|
+
const configPath = path.resolve(cwd, "edge-ota.config.json");
|
|
36
|
+
if (!fs.existsSync(configPath)) {
|
|
37
|
+
console.error("❌ edge-ota.config.json not found. Run `edge-ota init` first.");
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
41
|
+
}
|
|
42
|
+
function loadPrivateKey(cwd) {
|
|
43
|
+
const keyPath = path.resolve(cwd, ".edge-ota.private.key");
|
|
44
|
+
if (!fs.existsSync(keyPath)) {
|
|
45
|
+
console.error("❌ .edge-ota.private.key not found. Run `edge-ota init` first.");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
return fs.readFileSync(keyPath, "utf-8");
|
|
49
|
+
}
|
|
50
|
+
// ─── Archive helper ──────────────────────────────────────────────────────────
|
|
51
|
+
function zipDirectory(sourceDir, outPath) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
54
|
+
const stream = createWriteStream(outPath);
|
|
55
|
+
archive.directory(sourceDir, false);
|
|
56
|
+
archive.on("error", reject);
|
|
57
|
+
stream.on("close", resolve);
|
|
58
|
+
archive.pipe(stream);
|
|
59
|
+
archive.finalize();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// ─── SHA-256 of a file ───────────────────────────────────────────────────────
|
|
63
|
+
async function hashFile(filePath) {
|
|
64
|
+
const buffer = fs.readFileSync(filePath);
|
|
65
|
+
return sha256Hex(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
|
|
66
|
+
}
|
|
67
|
+
const CONTENT_TYPES = {
|
|
68
|
+
".js": "application/javascript",
|
|
69
|
+
".json": "application/json",
|
|
70
|
+
".png": "image/png",
|
|
71
|
+
".jpg": "image/jpeg",
|
|
72
|
+
".jpeg": "image/jpeg",
|
|
73
|
+
".gif": "image/gif",
|
|
74
|
+
".svg": "image/svg+xml",
|
|
75
|
+
".ttf": "font/ttf",
|
|
76
|
+
".otf": "font/otf",
|
|
77
|
+
".woff": "font/woff",
|
|
78
|
+
".woff2": "font/woff2",
|
|
79
|
+
".mp4": "video/mp4",
|
|
80
|
+
".webm": "video/webm"
|
|
81
|
+
};
|
|
82
|
+
async function collectAssets(distDir) {
|
|
83
|
+
const entries = [];
|
|
84
|
+
function walk(dir) {
|
|
85
|
+
for (const name of fs.readdirSync(dir)) {
|
|
86
|
+
const full = path.join(dir, name);
|
|
87
|
+
const stat = fs.statSync(full);
|
|
88
|
+
if (stat.isDirectory()) {
|
|
89
|
+
walk(full);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const ext = path.extname(name).toLowerCase();
|
|
93
|
+
const ct = CONTENT_TYPES[ext] || "application/octet-stream";
|
|
94
|
+
const key = path.relative(distDir, full).replace(/\\/g, "/");
|
|
95
|
+
entries.push({ localPath: full, key, contentType: ct, hash: "" });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
walk(distDir);
|
|
100
|
+
// Hash all assets in parallel
|
|
101
|
+
await Promise.all(entries.map(async (e) => {
|
|
102
|
+
e.hash = await hashFile(e.localPath);
|
|
103
|
+
}));
|
|
104
|
+
return entries;
|
|
105
|
+
}
|
|
106
|
+
// ─── Detect the JS bundle within the Expo export output ──────────────────────
|
|
107
|
+
function findBundle(distDir, platform) {
|
|
108
|
+
// Expo SDK 50+ writes: dist/_expo/static/js/<platform>/<hash>.js
|
|
109
|
+
const expoStaticJs = path.join(distDir, "_expo", "static", "js", platform);
|
|
110
|
+
if (fs.existsSync(expoStaticJs)) {
|
|
111
|
+
const files = fs.readdirSync(expoStaticJs).filter(f => f.endsWith(".js"));
|
|
112
|
+
if (files.length)
|
|
113
|
+
return path.join(expoStaticJs, files[0]);
|
|
114
|
+
}
|
|
115
|
+
// Fallback: older SDK flat layout
|
|
116
|
+
const flat = path.join(distDir, `index.${platform}.js`);
|
|
117
|
+
if (fs.existsSync(flat))
|
|
118
|
+
return flat;
|
|
119
|
+
// Any .js at root
|
|
120
|
+
const rootJs = fs.readdirSync(distDir).find(f => f.endsWith(".js"));
|
|
121
|
+
if (rootJs)
|
|
122
|
+
return path.join(distDir, rootJs);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
// ─── CLI definition ──────────────────────────────────────────────────────────
|
|
126
|
+
const program = new Command();
|
|
127
|
+
program
|
|
128
|
+
.name("edge-ota")
|
|
129
|
+
.description("Zero-SDK, self-hostable OTA update platform for Expo")
|
|
130
|
+
.version("0.2.0");
|
|
131
|
+
// ──────────────────────────────────────────────────────
|
|
132
|
+
// edge-ota init
|
|
133
|
+
// ──────────────────────────────────────────────────────
|
|
134
|
+
program
|
|
135
|
+
.command("init")
|
|
136
|
+
.description("Generate an ECDSA P-256 key pair and write edge-ota.config.json")
|
|
137
|
+
.option("-s, --server <url>", "EdgeOTA server URL", "http://localhost:3000")
|
|
138
|
+
.action(async (options) => {
|
|
139
|
+
console.log("⚙️ Generating ECDSA P-256 key pair...");
|
|
140
|
+
const keys = await generateECDSAKeyPair();
|
|
141
|
+
const config = {
|
|
142
|
+
serverUrl: options.server,
|
|
143
|
+
publicKey: keys.publicKey
|
|
144
|
+
};
|
|
145
|
+
fs.writeFileSync("edge-ota.config.json", JSON.stringify(config, null, 2));
|
|
146
|
+
fs.writeFileSync(".edge-ota.private.key", keys.privateKey, { mode: 0o600 });
|
|
147
|
+
// Add the private key to .gitignore if it isn't already
|
|
148
|
+
const gitignorePath = ".gitignore";
|
|
149
|
+
const gitignoreEntry = ".edge-ota.private.key";
|
|
150
|
+
if (fs.existsSync(gitignorePath)) {
|
|
151
|
+
const current = fs.readFileSync(gitignorePath, "utf-8");
|
|
152
|
+
if (!current.includes(gitignoreEntry)) {
|
|
153
|
+
fs.appendFileSync(gitignorePath, `\n${gitignoreEntry}\n`);
|
|
154
|
+
console.log("✅ Added .edge-ota.private.key to .gitignore");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
fs.writeFileSync(gitignorePath, `${gitignoreEntry}\n`);
|
|
159
|
+
}
|
|
160
|
+
console.log("\n✅ EdgeOTA initialised.");
|
|
161
|
+
console.log(" Config : edge-ota.config.json");
|
|
162
|
+
console.log(" Key : .edge-ota.private.key (keep this secret!)");
|
|
163
|
+
console.log("\n📋 Public key to paste into your dashboard → Settings → Infrastructure:\n");
|
|
164
|
+
console.log(keys.publicKey);
|
|
165
|
+
console.log("\n💡 Add this to your app.json:\n" +
|
|
166
|
+
' "updates": {\n' +
|
|
167
|
+
' "url": "' + options.server + '/api/updates"\n' +
|
|
168
|
+
" }");
|
|
169
|
+
});
|
|
170
|
+
// ──────────────────────────────────────────────────────
|
|
171
|
+
// edge-ota push
|
|
172
|
+
// ──────────────────────────────────────────────────────
|
|
173
|
+
program
|
|
174
|
+
.command("push")
|
|
175
|
+
.description("Export the Expo bundle, compute per-asset hashes, sign & upload to the EdgeOTA server")
|
|
176
|
+
.option("-c, --channel <channel>", "Deployment channel", "production")
|
|
177
|
+
.option("-r, --runtime <version>", "React Native runtime version (semver)", "1.0.0")
|
|
178
|
+
.option("-p, --platform <platform>", "Target platform: ios | android | all", "all")
|
|
179
|
+
.option("--skip-export", "Skip expo export (use existing ./dist directory)")
|
|
180
|
+
.option("--dry-run", "Build and sign the payload but do NOT upload")
|
|
181
|
+
.action(async (options) => {
|
|
182
|
+
const cwd = process.cwd();
|
|
183
|
+
const config = loadConfig(cwd);
|
|
184
|
+
const privateKey = loadPrivateKey(cwd);
|
|
185
|
+
// ── Step 1: Build ────────────────────────────────────────────────────────
|
|
186
|
+
const distDir = path.resolve(cwd, "dist");
|
|
187
|
+
if (!options.skipExport) {
|
|
188
|
+
console.log("📦 Running expo export...");
|
|
189
|
+
try {
|
|
190
|
+
execSync("npx expo export", { stdio: "inherit", cwd });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
console.error("❌ expo export failed. Aborting.");
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
console.log("⏭️ Skipping expo export (--skip-export)");
|
|
199
|
+
}
|
|
200
|
+
if (!fs.existsSync(distDir)) {
|
|
201
|
+
console.error("❌ ./dist directory not found after export. Aborting.");
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
// ── Step 2: Collect assets & hashes ─────────────────────────────────────
|
|
205
|
+
console.log("🔍 Collecting assets and computing SHA-256 hashes...");
|
|
206
|
+
const assets = await collectAssets(distDir);
|
|
207
|
+
console.log(` Found ${assets.length} asset(s)`);
|
|
208
|
+
const platforms = options.platform === "all" ? ["ios", "android"] : [options.platform];
|
|
209
|
+
for (const platform of platforms) {
|
|
210
|
+
console.log(`\n🚀 Processing platform: ${platform.toUpperCase()}`);
|
|
211
|
+
// Find the JS bundle
|
|
212
|
+
const bundlePath = findBundle(distDir, platform);
|
|
213
|
+
if (!bundlePath) {
|
|
214
|
+
console.warn(`⚠️ No bundle found for ${platform}. Skipping.`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const bundleHash = await hashFile(bundlePath);
|
|
218
|
+
const bundleAsset = assets.find(a => a.localPath === bundlePath);
|
|
219
|
+
// ── Step 3: Build the signed payload ──────────────────────────────────
|
|
220
|
+
const payloadObj = {
|
|
221
|
+
channel: options.channel,
|
|
222
|
+
runtimeVersion: options.runtime,
|
|
223
|
+
platform,
|
|
224
|
+
bundleHash,
|
|
225
|
+
timestamp: Date.now(),
|
|
226
|
+
assetCount: assets.length
|
|
227
|
+
};
|
|
228
|
+
const payloadStr = JSON.stringify(payloadObj);
|
|
229
|
+
console.log("🔏 Signing payload with ECDSA P-256...");
|
|
230
|
+
const signature = await signPayload(payloadStr, privateKey);
|
|
231
|
+
console.log(` Signature: ${signature.slice(0, 24)}…`);
|
|
232
|
+
if (options.dryRun) {
|
|
233
|
+
console.log("🏳️ Dry run — skipping upload.");
|
|
234
|
+
console.log(" Payload:", payloadObj);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// ── Step 4: Upload ─────────────────────────────────────────────────────
|
|
238
|
+
// Pack the whole dist directory into a zip for server-side extraction
|
|
239
|
+
const zipPath = path.resolve(cwd, `update-bundle-${platform}.zip`);
|
|
240
|
+
console.log("🗜️ Zipping bundle...");
|
|
241
|
+
await zipDirectory(distDir, zipPath);
|
|
242
|
+
const zipBuffer = fs.readFileSync(zipPath);
|
|
243
|
+
// Build multipart/form-data manually (Node 18+ has FormData but
|
|
244
|
+
// Blob.arrayBuffer is not always available; use Buffer directly).
|
|
245
|
+
const form = new FormData();
|
|
246
|
+
form.append("bundle", new Blob([zipBuffer], { type: "application/zip" }), `bundle-${platform}.zip`);
|
|
247
|
+
form.append("payload", payloadStr);
|
|
248
|
+
form.append("signature", signature);
|
|
249
|
+
form.append("platform", platform);
|
|
250
|
+
console.log(`📡 Uploading to ${config.serverUrl}/api/updates ...`);
|
|
251
|
+
const response = await fetch(`${config.serverUrl}/api/updates`, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
body: form
|
|
254
|
+
// Note: do NOT set Content-Type; fetch sets it automatically
|
|
255
|
+
// with the correct multipart boundary.
|
|
256
|
+
});
|
|
257
|
+
// Clean up zip
|
|
258
|
+
fs.unlinkSync(zipPath);
|
|
259
|
+
if (response.ok) {
|
|
260
|
+
const body = await response.json().catch(() => ({}));
|
|
261
|
+
const updateId = body.updateId || "unknown";
|
|
262
|
+
console.log(`\n✅ ${platform.toUpperCase()} update deployed!`);
|
|
263
|
+
console.log(` Update ID : ${updateId}`);
|
|
264
|
+
console.log(` Channel : ${options.channel}`);
|
|
265
|
+
console.log(` Runtime : ${options.runtime}`);
|
|
266
|
+
console.log(` Bundle : ${bundleHash.slice(0, 16)}…`);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
const text = await response.text();
|
|
270
|
+
console.error(`\n❌ Upload failed (HTTP ${response.status}): ${text}`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
console.log("\n🎉 Done. Your app will receive this update on the next OTA sync.\n" +
|
|
275
|
+
" Powered by EdgeOTA — https://github.com/edge-ota");
|
|
276
|
+
});
|
|
277
|
+
// ──────────────────────────────────────────────────────
|
|
278
|
+
// edge-ota status
|
|
279
|
+
// ──────────────────────────────────────────────────────
|
|
280
|
+
program
|
|
281
|
+
.command("status")
|
|
282
|
+
.description("List recent releases from the EdgeOTA server")
|
|
283
|
+
.option("-t, --token <token>", "Auth token (or set EDGE_OTA_TOKEN env var)")
|
|
284
|
+
.option("-n, --limit <n>", "Number of releases to show", "10")
|
|
285
|
+
.action(async (options) => {
|
|
286
|
+
const cwd = process.cwd();
|
|
287
|
+
const config = loadConfig(cwd);
|
|
288
|
+
const token = options.token || process.env.EDGE_OTA_TOKEN;
|
|
289
|
+
if (!token) {
|
|
290
|
+
console.error("❌ Auth token required. Use -t <token> or set EDGE_OTA_TOKEN.");
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
const res = await fetch(`${config.serverUrl}/api/releases`, {
|
|
294
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
295
|
+
});
|
|
296
|
+
if (!res.ok) {
|
|
297
|
+
console.error(`❌ Failed to fetch releases (HTTP ${res.status})`);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
const releases = (await res.json());
|
|
301
|
+
if (!releases.length) {
|
|
302
|
+
console.log("No releases found.");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const rows = releases.slice(0, parseInt(options.limit)).map((r) => ({
|
|
306
|
+
ID: r.id.slice(0, 8),
|
|
307
|
+
Channel: r.channel,
|
|
308
|
+
Runtime: r.runtime,
|
|
309
|
+
Status: r.status,
|
|
310
|
+
Published: r.published,
|
|
311
|
+
"By": r.publisher
|
|
312
|
+
}));
|
|
313
|
+
console.table(rows);
|
|
314
|
+
});
|
|
315
|
+
// ──────────────────────────────────────────────────────
|
|
316
|
+
// edge-ota keygen
|
|
317
|
+
// ──────────────────────────────────────────────────────
|
|
318
|
+
program
|
|
319
|
+
.command("keygen")
|
|
320
|
+
.description("Generate a fresh ECDSA key pair (prints to stdout, does not write files)")
|
|
321
|
+
.action(async () => {
|
|
322
|
+
const keys = await generateECDSAKeyPair();
|
|
323
|
+
console.log("--- PRIVATE KEY (keep secret) ---");
|
|
324
|
+
console.log(keys.privateKey);
|
|
325
|
+
console.log("\n--- PUBLIC KEY (paste into dashboard) ---");
|
|
326
|
+
console.log(keys.publicKey);
|
|
327
|
+
});
|
|
328
|
+
program.parse(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@renbostudios/edge-ota",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-SDK, self-hostable OTA update CLI for Expo — push signed JS bundles to any EdgeOTA server",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "EdgeOTA Contributors",
|
|
7
|
+
"homepage": "https://github.com/Renbo-Studios/edge-ota",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/Renbo-Studios/edge-ota.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Renbo-Studios/edge-ota/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["expo", "ota", "updates", "react-native", "cli", "edge", "cloudflare"],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"bin": {
|
|
18
|
+
"edge-ota": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc",
|
|
26
|
+
"dev": "tsc --watch",
|
|
27
|
+
"clean": "rm -rf dist",
|
|
28
|
+
"prepublishOnly": "npm run build"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"commander": "^12.1.0",
|
|
32
|
+
"archiver": "^7.0.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"typescript": "^5.4.5",
|
|
36
|
+
"@types/node": "^20.12.12",
|
|
37
|
+
"@types/archiver": "^6.0.2"
|
|
38
|
+
}
|
|
39
|
+
}
|