@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 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,2 @@
1
+ export * from "./crypto.js";
2
+ export * from "./manifest.js";
@@ -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
+ }