@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 CHANGED
@@ -1,125 +1,26 @@
1
- # `@edge-ota/cli`
1
+ # @renbostudios/edge-ota CLI
2
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.
3
+ Zero-SDK, self-hostable OTA updates for Expo.
5
4
 
6
- ## Install
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
- ### `edge-ota status [options]`
73
-
74
- Lists recent releases from the server as a table.
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 keygen
21
+ npm install -g @renbostudios/edge-ota
95
22
  ```
96
23
 
97
24
  ---
98
25
 
99
- ## How it works
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
@@ -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 proper boundary
7
+ * - Multipart/mixed response body with FormData-compatible boundary
7
8
  * - expo-signature header for ECDSA bundle verification
8
- * - Structured extension (extra) metadata passthrough
9
+ * - fileExtension on launchAsset and assets
10
+ * - extra.expoClient config passthrough
9
11
  */
10
12
  /**
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).
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: "bundle",
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, must-revalidate",
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 when the client sends
77
- * `Accept: multipart/mixed` (expo-updates >=0.18).
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
- * <manifest JSON>
85
- * --<boundary>--
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 = "expo-update-boundary-" + Math.random().toString(36).slice(2, 10);
86
+ const boundary = "ota" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
89
87
  const manifestJson = JSON.stringify(manifest);
90
- let partHeaders = "Content-Disposition: inline; name=\"manifest\"\r\n";
91
- partHeaders += "Content-Type: application/json\r\n";
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
- partHeaders += `expo-signature: sig="${signature}", keyid="root", alg="ecdsa-p256-sha256"\r\n`;
93
+ manifestPart += `expo-signature: sig="${signature}", keyid="root", alg="ecdsa-p256-sha256"\r\n`;
94
94
  }
95
- const body = `--${boundary}\r\n` +
96
- `${partHeaders}\r\n` +
97
- `${manifestJson}\r\n` +
98
- `--${boundary}--\r\n`;
99
- return { body, boundary };
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
- * Phase 1: Build / Upload
6
- * ─────────────────────────────────────────────────────────────────────────
7
- * 1. `edge-ota init` – Generate ECDSA P-256 key pair, write config + private key.
8
- * 2. `edge-ota push` – Run `expo export`, collect JS bundle + assets, compute
9
- * per-asset SHA-256 hashes, sign the upload payload with the
10
- * private key, POST a multipart/form-data to /api/updates.
11
- * 3. `edge-ota status` – List recent releases and their channel assignments.
12
- *
13
- * The server verifies the ECDSA signature before accepting the bundle,
14
- * then stores assets on disk (server-node) or R2 (worker-oss) and
15
- * records the update in the database.
16
- *
17
- * From that point the Expo Updates SDK handshake is:
18
- * GET /api/updates
19
- * expo-platform: ios | android
20
- * expo-runtime-version: <semver>
21
- * expo-channel-name: production | staging | …
22
- *
23
- * The server responds with an Expo Updates v1 manifest JSON (signed if a
24
- * public key is configured) and the SDK fetches launchAsset.url to swap
25
- * the active bundle.
7
+ * Commands:
8
+ * edge-ota login — Authenticate and save credentials globally
9
+ * edge-ota logout — Remove stored credentials
10
+ * edge-ota init — Register project, generate signing keys, configure app.json
11
+ * edge-ota push — Export bundle and publish an OTA update
12
+ * edge-ota status — List recent deployments for this project
13
+ * edge-ota keygen — Generate a new ECDSA key pair (prints to stdout)
26
14
  */
27
15
  import { Command } from "commander";
28
- import { execSync } from "child_process";
16
+ import { spawn } from "child_process";
29
17
  import fs from "fs";
18
+ import os from "os";
30
19
  import path from "path";
31
- import { createWriteStream } from "fs";
32
- import archiver from "archiver";
33
20
  import readline from "readline";
21
+ import { fileURLToPath } from "url";
34
22
  import { generateECDSAKeyPair, signPayload, sha256Hex } from "./core/index.js";
35
- // ─── Auto-load .env from the project root ────────────────────────────────────
36
- // Lets users store EDGE_OTA_TOKEN in a .env file without shell exports.
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = path.dirname(__filename);
25
+ // ─── Global Config Paths ─────────────────────────────────────────────────────
26
+ const GLOBAL_CONFIG_DIR = path.join(os.homedir(), ".config", "edge-ota");
27
+ const GLOBAL_CONFIG_FILE = path.join(GLOBAL_CONFIG_DIR, "config.json");
28
+ const GLOBAL_KEYS_DIR = path.join(GLOBAL_CONFIG_DIR, "keys");
29
+ function loadGlobalConfig() {
30
+ try {
31
+ if (fs.existsSync(GLOBAL_CONFIG_FILE)) {
32
+ return JSON.parse(fs.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
33
+ }
34
+ }
35
+ catch { /* ignore */ }
36
+ return null;
37
+ }
38
+ function saveGlobalConfig(config) {
39
+ fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
40
+ fs.writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
41
+ }
42
+ function clearGlobalConfig() {
43
+ try {
44
+ fs.rmSync(GLOBAL_CONFIG_FILE);
45
+ }
46
+ catch { /* ignore */ }
47
+ }
48
+ function loadPrivateKey(projectId) {
49
+ // 1. Global key store (new location)
50
+ const globalKeyPath = path.join(GLOBAL_KEYS_DIR, `${projectId}.key`);
51
+ if (fs.existsSync(globalKeyPath)) {
52
+ return fs.readFileSync(globalKeyPath, "utf-8").trim();
53
+ }
54
+ // 2. Legacy local file (backward compat with deprecation notice)
55
+ const localKeyPath = path.resolve(process.cwd(), ".edge-ota.private.key");
56
+ if (fs.existsSync(localKeyPath)) {
57
+ spin.stop();
58
+ console.log(` ${c.yellow}⚠${c.reset} Using legacy ${c.dim}.edge-ota.private.key${c.reset} in project directory.`);
59
+ console.log(` Move it to ${c.dim}${globalKeyPath}${c.reset} and delete the local copy.`);
60
+ return fs.readFileSync(localKeyPath, "utf-8").trim();
61
+ }
62
+ return null;
63
+ }
64
+ function savePrivateKey(projectId, key) {
65
+ fs.mkdirSync(GLOBAL_KEYS_DIR, { recursive: true });
66
+ const keyPath = path.join(GLOBAL_KEYS_DIR, `${projectId}.key`);
67
+ fs.writeFileSync(keyPath, key, { mode: 0o600 });
68
+ }
69
+ // ─── Auto-load .env ───────────────────────────────────────────────────────────
37
70
  try {
38
71
  const envPath = path.resolve(process.cwd(), ".env");
39
72
  if (fs.existsSync(envPath)) {
40
- const lines = fs.readFileSync(envPath, "utf8").split("\n");
41
- for (const line of lines) {
42
- const trimmed = line.trim();
43
- if (!trimmed || trimmed.startsWith("#"))
73
+ for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
74
+ const t = line.trim();
75
+ if (!t || t.startsWith("#"))
44
76
  continue;
45
- const eqIdx = trimmed.indexOf("=");
46
- if (eqIdx === -1)
77
+ const eq = t.indexOf("=");
78
+ if (eq === -1)
47
79
  continue;
48
- const key = trimmed.slice(0, eqIdx).trim();
49
- const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, "");
50
- if (!(key in process.env))
51
- process.env[key] = val;
80
+ const k = t.slice(0, eq).trim();
81
+ const v = t.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
82
+ if (!(k in process.env))
83
+ process.env[k] = v;
52
84
  }
53
85
  }
54
86
  }
55
- catch { /* silently skip if .env is unreadable */ }
56
- // ─── ANSI Styling Helpers ───────────────────────────────────────────────────
57
- const colors = {
87
+ catch { /* silently skip */ }
88
+ // ─── ANSI Colors ─────────────────────────────────────────────────────────────
89
+ const c = {
58
90
  reset: "\x1b[0m",
59
91
  bold: "\x1b[1m",
60
92
  dim: "\x1b[2m",
61
93
  underline: "\x1b[4m",
62
- // Foreground Colors
63
- black: "\x1b[30m",
64
94
  red: "\x1b[31m",
65
95
  green: "\x1b[32m",
66
96
  yellow: "\x1b[33m",
67
97
  blue: "\x1b[34m",
68
- magenta: "\x1b[35m",
69
98
  cyan: "\x1b[36m",
70
99
  white: "\x1b[37m",
71
100
  gray: "\x1b[90m",
72
101
  };
73
- function printBanner() {
74
- const line = `${colors.dim}${"-".repeat(48)}${colors.reset}`;
75
- console.log(`\n${line}`);
76
- console.log(` ${colors.bold}${colors.white}edge-ota${colors.reset} ${colors.dim}v0.2.4 · Zero-SDK OTA for Expo${colors.reset}`);
77
- console.log(` ${colors.dim}by Renbo Studios · renbostudios.com${colors.reset}`);
78
- console.log(`${line}\n`);
102
+ // ─── Spinner ─────────────────────────────────────────────────────────────────
103
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
104
+ const spin = (() => {
105
+ let timer = null;
106
+ let frame = 0;
107
+ let label = "";
108
+ return {
109
+ start(text) {
110
+ label = text;
111
+ frame = 0;
112
+ if (!process.stdout.isTTY) {
113
+ process.stdout.write(` ${text}...\n`);
114
+ return;
115
+ }
116
+ timer = setInterval(() => {
117
+ const f = `${c.cyan}${FRAMES[frame % FRAMES.length]}${c.reset}`;
118
+ process.stdout.write(`\r ${f} ${c.dim}${label}${c.reset} `);
119
+ frame++;
120
+ }, 80);
121
+ },
122
+ update(text) {
123
+ label = text;
124
+ },
125
+ stop(successMsg) {
126
+ if (timer) {
127
+ clearInterval(timer);
128
+ timer = null;
129
+ }
130
+ if (process.stdout.isTTY) {
131
+ process.stdout.write("\r\x1b[K"); // clear line
132
+ }
133
+ if (successMsg) {
134
+ console.log(` ${c.green}✓${c.reset} ${successMsg}`);
135
+ }
136
+ },
137
+ fail(errMsg) {
138
+ if (timer) {
139
+ clearInterval(timer);
140
+ timer = null;
141
+ }
142
+ if (process.stdout.isTTY)
143
+ process.stdout.write("\r\x1b[K");
144
+ console.error(` ${c.red}✗${c.reset} ${errMsg}`);
145
+ }
146
+ };
147
+ })();
148
+ // ─── Package version ─────────────────────────────────────────────────────────
149
+ let packageVersion = "0.2.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
- function askQuestion(query) {
81
- const rl = readline.createInterface({
82
- input: process.stdin,
83
- output: process.stdout
84
- });
85
- return new Promise((resolve) => {
86
- rl.question(query, (answer) => {
87
- rl.close();
88
- resolve(answer.trim());
89
- });
90
- });
156
+ catch { /* fallback */ }
157
+ // ─── Banner ───────────────────────────────────────────────────────────────────
158
+ function printBanner() {
159
+ const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
160
+ console.log(`\n${sep}`);
161
+ console.log(` ${c.bold}${c.white}edge-ota${c.reset} ${c.dim}v${packageVersion} · Zero-SDK OTA for Expo${c.reset}`);
162
+ console.log(` ${c.dim}by ${c.reset}${c.bold}Renbo Studios${c.reset}${c.dim} — Mobile App Development Studio${c.reset}`);
163
+ console.log(` ${c.dim}renbostudios.com${c.reset}`);
164
+ console.log(`${sep}\n`);
91
165
  }
92
- function updateAppJson(cwd, serverUrl, projectId) {
166
+ function readAppJson(cwd) {
93
167
  const appJsonPath = path.resolve(cwd, "app.json");
94
168
  if (!fs.existsSync(appJsonPath)) {
95
- console.log("⚠️ app.json not found in this directory. Skipping auto-update.");
96
- return;
169
+ console.error(`\n ${c.red}✗${c.reset} ${c.bold}app.json not found${c.reset} in ${cwd}`);
170
+ console.error(` Run this command from your Expo project root.\n`);
171
+ process.exit(1);
97
172
  }
173
+ let data;
98
174
  try {
99
- const raw = fs.readFileSync(appJsonPath, "utf-8");
100
- const data = JSON.parse(raw);
175
+ data = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
176
+ }
177
+ catch {
178
+ console.error(` ${c.red}✗${c.reset} Failed to parse app.json`);
179
+ process.exit(1);
180
+ }
181
+ const expo = data?.expo ?? {};
182
+ // ── Runtime version ──
183
+ const runtimeVersion = expo.runtimeVersion;
184
+ if (!runtimeVersion) {
185
+ console.error(` ${c.red}✗${c.reset} ${c.bold}expo.runtimeVersion${c.reset} is not set in app.json`);
186
+ console.error(` Add: ${c.dim}"runtimeVersion": "1.0.0"${c.reset} under the expo key.\n`);
187
+ process.exit(1);
188
+ }
189
+ // ── Updates URL ──
190
+ const updatesUrl = expo?.updates?.url ?? "";
191
+ if (!updatesUrl) {
192
+ console.error(` ${c.red}✗${c.reset} ${c.bold}expo.updates.url${c.reset} is not configured in app.json`);
193
+ console.error(` Run ${c.cyan}edge-ota init${c.reset} to set this up.\n`);
194
+ process.exit(1);
195
+ }
196
+ // Parse serverUrl and projectId from the updates URL
197
+ // Expected format: https://<host>/api/projects/<uuid>/updates
198
+ // or: https://<host>/api/updates
199
+ let serverUrl;
200
+ let projectId = null;
201
+ try {
202
+ const u = new URL(updatesUrl);
203
+ const projectMatch = u.pathname.match(/\/api\/projects\/([^/]+)\/updates/);
204
+ if (projectMatch) {
205
+ projectId = projectMatch[1];
206
+ serverUrl = `${u.protocol}//${u.host}`;
207
+ }
208
+ else {
209
+ serverUrl = `${u.protocol}//${u.host}`;
210
+ }
211
+ }
212
+ catch {
213
+ console.error(` ${c.red}✗${c.reset} Could not parse ${c.dim}expo.updates.url${c.reset} in app.json`);
214
+ process.exit(1);
215
+ }
216
+ return { serverUrl, projectId, runtimeVersion, publicKey: expo.updates?.publicKey };
217
+ }
218
+ function updateAppJson(cwd, serverUrl, projectId, publicKey) {
219
+ const appJsonPath = path.resolve(cwd, "app.json");
220
+ if (!fs.existsSync(appJsonPath))
221
+ return;
222
+ try {
223
+ const data = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
101
224
  if (!data.expo)
102
225
  data.expo = {};
103
226
  if (!data.expo.updates)
104
227
  data.expo.updates = {};
105
- const updateUrl = projectId
106
- ? `${serverUrl.replace(/\/$/, "")}/api/projects/${projectId}/updates`
107
- : `${serverUrl.replace(/\/$/, "")}/api/updates`;
108
- data.expo.updates.url = updateUrl;
109
- fs.writeFileSync(appJsonPath, JSON.stringify(data, null, 2), "utf-8");
110
- console.log(` ${colors.dim}app.json${colors.reset} updates.url set`);
111
- }
112
- catch (error) {
113
- console.error(` ${colors.red}error${colors.reset} Failed to update app.json: ${error.message}`);
228
+ const cleanUrl = serverUrl.replace(/\/$/, "");
229
+ data.expo.updates.url = projectId
230
+ ? `${cleanUrl}/api/projects/${projectId}/updates`
231
+ : `${cleanUrl}/api/updates`;
232
+ data.expo.updates.checkAutomatically = data.expo.updates.checkAutomatically ?? "ON_LOAD";
233
+ data.expo.updates.fallbackToCacheTimeout = data.expo.updates.fallbackToCacheTimeout ?? 30000;
234
+ data.expo.updates.requestHeaders = data.expo.updates.requestHeaders ?? { "expo-channel-name": "production" };
235
+ if (publicKey) {
236
+ data.expo.updates.codeSigningCertificate = undefined; // clear legacy
237
+ data.expo.extra = data.expo.extra ?? {};
238
+ data.expo.extra.edgeOtaPublicKey = publicKey;
239
+ }
240
+ fs.writeFileSync(appJsonPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
241
+ console.log(` ${c.dim}app.json${c.reset} updated ${c.dim}expo.updates.url${c.reset}`);
114
242
  }
115
- }
116
- function loadConfig(cwd) {
117
- const configPath = path.resolve(cwd, "edge-ota.config.json");
118
- if (!fs.existsSync(configPath)) {
119
- console.error("❌ edge-ota.config.json not found. Run `edge-ota init` first.");
120
- process.exit(1);
243
+ catch (e) {
244
+ console.error(` ${c.yellow}⚠${c.reset} Could not update app.json: ${e.message}`);
121
245
  }
122
- return JSON.parse(fs.readFileSync(configPath, "utf-8"));
123
246
  }
124
- function loadPrivateKey(cwd) {
125
- const keyPath = path.resolve(cwd, ".edge-ota.private.key");
126
- if (!fs.existsSync(keyPath)) {
127
- console.error("❌ .edge-ota.private.key not found. Run `edge-ota init` first.");
128
- process.exit(1);
129
- }
130
- return fs.readFileSync(keyPath, "utf-8");
247
+ // ─── Readline helper ─────────────────────────────────────────────────────────
248
+ function ask(query) {
249
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
250
+ return new Promise(resolve => rl.question(query, a => { rl.close(); resolve(a.trim()); }));
131
251
  }
132
- // ─── Archive helper ──────────────────────────────────────────────────────────
133
- function zipDirectory(sourceDir, outPath) {
134
- return new Promise((resolve, reject) => {
135
- const archive = archiver("zip", { zlib: { level: 9 } });
136
- const stream = createWriteStream(outPath);
137
- archive.directory(sourceDir, false);
138
- archive.on("error", reject);
139
- stream.on("close", resolve);
140
- archive.pipe(stream);
141
- archive.finalize();
252
+ function askSecret(query) {
253
+ return new Promise(resolve => {
254
+ if (process.stdin.isTTY) {
255
+ process.stdout.write(query);
256
+ let input = "";
257
+ process.stdin.setRawMode(true);
258
+ process.stdin.resume();
259
+ process.stdin.setEncoding("utf8");
260
+ process.stdin.on("data", function onData(ch) {
261
+ if (ch === "\r" || ch === "\n") {
262
+ process.stdin.setRawMode(false);
263
+ process.stdin.removeListener("data", onData);
264
+ process.stdout.write("\n");
265
+ resolve(input);
266
+ }
267
+ else if (ch === "\u0003") {
268
+ process.exit(0);
269
+ }
270
+ else if (ch === "\u007f") {
271
+ if (input.length > 0) {
272
+ input = input.slice(0, -1);
273
+ process.stdout.write("\b \b");
274
+ }
275
+ }
276
+ else {
277
+ input += ch;
278
+ process.stdout.write("•");
279
+ }
280
+ });
281
+ }
282
+ else {
283
+ ask(query).then(resolve);
284
+ }
142
285
  });
143
286
  }
144
- // ─── SHA-256 of a file ───────────────────────────────────────────────────────
145
- async function hashFile(filePath) {
146
- const buffer = fs.readFileSync(filePath);
147
- return sha256Hex(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
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
- const stat = fs.statSync(full);
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
- const ct = CONTENT_TYPES[ext] || "application/octet-stream";
177
- const key = path.relative(distDir, full).replace(/\\/g, "/");
178
- entries.push({ localPath: full, key, contentType: ct, hash: "" });
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
- // Hash all assets in parallel
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
- // ─── CLI definition ──────────────────────────────────────────────────────────
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, self-hostable OTA update platform for Expo")
216
- .version("0.2.0");
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("Generate an ECDSA P-256 key pair and write edge-ota.config.json")
223
- .option("-s, --server <url>", "EdgeOTA server URL")
224
- .option("--project <id>", "EdgeOTA Project ID (optional)")
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
- let serverUrl = options.server;
228
- let projectId = options.project;
229
- if (!serverUrl) {
230
- console.log(` ${colors.dim}no --server flag provided${colors.reset}`);
231
- const answer = await askQuestion(` server url [https://api.edge-ota.renbo.site]: `);
232
- serverUrl = answer.trim() || "https://api.edge-ota.renbo.site";
233
- }
234
- if (!projectId && !options.server) {
235
- const pAnswer = await askQuestion(` project id (optional, press Enter to skip): `);
236
- if (pAnswer.trim())
237
- projectId = pAnswer.trim();
238
- }
239
- serverUrl = serverUrl.replace(/\/$/, "");
240
- console.log(` ${colors.dim}generating ECDSA P-256 key pair...${colors.reset}`);
241
- const keys = await generateECDSAKeyPair();
242
- const config = { serverUrl, publicKey: keys.publicKey };
243
- if (projectId)
244
- config.projectId = projectId;
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
- fs.writeFileSync("edge-ota.config.json", JSON.stringify(config, null, 2));
247
- fs.writeFileSync(".edge-ota.private.key", keys.privateKey, { mode: 0o600 });
248
- const gitignorePath = ".gitignore";
249
- const gitignoreEntry = ".edge-ota.private.key";
250
- if (fs.existsSync(gitignorePath)) {
251
- const current = fs.readFileSync(gitignorePath, "utf-8");
252
- if (!current.includes(gitignoreEntry)) {
253
- fs.appendFileSync(gitignorePath, `\n${gitignoreEntry}\n`);
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
- else {
257
- fs.writeFileSync(gitignorePath, `${gitignoreEntry}\n`);
491
+ catch (e) {
492
+ spin.fail(`Connection error: ${e.message}`);
493
+ process.exit(1);
258
494
  }
259
- updateAppJson(cwd, serverUrl, projectId);
260
- const targetUrlStr = projectId
261
- ? `${serverUrl}/api/projects/${projectId}/updates`
262
- : `${serverUrl}/api/updates`;
263
- const sep = `${colors.dim}${"-".repeat(48)}${colors.reset}`;
264
- console.log(`\n ${colors.green}✓${colors.reset} ${colors.bold}initialised${colors.reset}`);
265
- console.log(sep);
266
- console.log(` config ${colors.cyan}edge-ota.config.json${colors.reset}`);
267
- console.log(` key ${colors.yellow}.edge-ota.private.key${colors.reset} ${colors.dim}(keep secret)${colors.reset}`);
268
- console.log(` endpoint ${colors.dim}${targetUrlStr}${colors.reset}`);
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(`\n ${colors.bold}public key${colors.reset} ${colors.dim}paste into dashboard → Settings → General${colors.reset}\n`);
271
- console.log(`${colors.dim}${keys.publicKey}${colors.reset}\n`);
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 the Expo bundle, compute per-asset hashes, sign & upload to the EdgeOTA server")
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 config = loadConfig(cwd);
289
- const privateKey = loadPrivateKey(cwd);
290
- // Resolve token — CLI flag > env var
291
- const token = options.token || process.env.EDGE_OTA_TOKEN;
521
+ const token = resolveToken();
522
+ printBanner();
292
523
  if (!token) {
293
- console.error(`${colors.bold}${colors.red}❌ Auth token required. Use --token <token> or set EDGE_OTA_TOKEN env var.${colors.reset}`);
294
- console.error(` Generate a deploy key from your dashboard → Keys page.`);
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
- ? `${config.serverUrl}/api/projects/${projectId}/updates`
302
- : `${config.serverUrl}/api/updates`;
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
- console.log(` ${colors.dim}running expo export...${colors.reset}`);
308
- try {
309
- execSync("npx expo export", { stdio: "inherit", cwd });
310
- }
311
- catch {
312
- console.error(` ${colors.red}error${colors.reset} expo export failed`);
313
- process.exit(1);
314
- }
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(` ${colors.dim}skipping expo export (--skip-export)${colors.reset}`);
591
+ console.log(` ${c.dim}skip-export — using existing ./dist${c.reset}`);
318
592
  }
319
593
  if (!fs.existsSync(distDir)) {
320
- console.error(` ${colors.red}error${colors.reset} ./dist not found after export`);
594
+ console.error(` ${c.red}✗${c.reset} ./dist not found after export\n`);
321
595
  process.exit(1);
322
596
  }
323
- console.log(` ${colors.dim}collecting assets...${colors.reset}`);
597
+ // ── Step 2: Collect assets ───────────────────────────────────────────────
598
+ spin.start("collecting assets");
324
599
  const assets = await collectAssets(distDir);
325
- console.log(` ${colors.dim}found ${assets.length} asset(s)${projectId ? ` · project ${projectId}` : ""}${colors.reset}`);
600
+ spin.stop(`found ${assets.length} asset(s)`);
326
601
  const platforms = options.platform === "all" ? ["ios", "android"] : [options.platform];
327
- const sep = `${colors.dim}${"-".repeat(48)}${colors.reset}`;
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(` ${colors.bold}${platform}${colors.reset}`);
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(` ${colors.yellow}warn${colors.reset} no bundle found for ${platform}, skipping`);
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: options.runtime,
618
+ runtimeVersion,
341
619
  platform,
342
620
  bundleHash,
343
621
  timestamp: Date.now(),
344
622
  assetCount: assets.length,
345
- publicKey: config.publicKey
623
+ publicKey: appCfg.publicKey,
346
624
  };
347
625
  const payloadStr = JSON.stringify(payloadObj);
348
- console.log(` signing ECDSA P-256`);
626
+ spin.start("signing");
349
627
  const signature = await signPayload(payloadStr, privateKey);
350
- console.log(` sig ${colors.dim}${signature.slice(0, 24)}…${colors.reset}`);
628
+ spin.stop(`signed ${c.dim}${signature.slice(0, 20)}…${c.reset}`);
351
629
  if (options.dryRun) {
352
- console.log(` ${colors.yellow}dry-run${colors.reset} upload skipped`);
353
- console.log(" payload ", payloadObj);
630
+ console.log(` ${c.yellow}dry-run${c.reset} upload skipped`);
354
631
  continue;
355
632
  }
356
- console.log(` packing bundle`);
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
- "Authorization": `Bearer ${token}`,
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
- console.error(` ${colors.red}error${colors.reset} upload failed (HTTP ${response.status}): ${text}`);
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${colors.dim}${"-".repeat(48)}${colors.reset}`);
388
- console.log(` ${colors.green}✓${colors.reset} ${colors.bold}done${colors.reset} update will be applied on next OTA sync`);
389
- console.log(`${colors.dim}${"-".repeat(48)}${colors.reset}\n`);
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 releases from the EdgeOTA server")
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
- const cwd = process.cwd();
402
- const config = loadConfig(cwd);
403
- const token = options.token || process.env.EDGE_OTA_TOKEN;
404
- const projectId = options.project || config.projectId;
675
+ printBanner();
676
+ const token = resolveToken();
405
677
  if (!token) {
406
- console.error(` ${colors.red}error${colors.reset} token required — set EDGE_OTA_TOKEN in .env or use -t`);
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
- const res = await fetch(`${config.serverUrl}/api/releases`, { headers });
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
- console.error(` ${colors.red}error${colors.reset} failed to fetch releases (HTTP ${res.status})`);
693
+ spin.fail(`Failed to fetch releases (HTTP ${res.status})`);
415
694
  process.exit(1);
416
695
  }
417
- const releases = (await res.json());
696
+ const releases = await res.json();
697
+ spin.stop();
418
698
  if (!releases.length) {
419
- console.log(` ${colors.dim}no releases found${colors.reset}`);
699
+ console.log(` ${c.dim}No releases found.${c.reset}\n`);
420
700
  return;
421
701
  }
422
- const rows = releases.slice(0, parseInt(options.limit)).map((r) => ({
423
- ID: r.id.slice(0, 8),
424
- Channel: r.channel,
425
- Runtime: r.runtime,
426
- Status: r.status,
427
- Published: r.published,
428
- By: r.publisher
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 = `${colors.dim}${"-".repeat(48)}${colors.reset}`;
720
+ const sep = `${c.dim}${"─".repeat(52)}${c.reset}`;
441
721
  console.log(`\n${sep}`);
442
- console.log(` ${colors.bold}private key${colors.reset} ${colors.dim}keep secret — never commit${colors.reset}`);
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(` ${colors.bold}public key${colors.reset} ${colors.dim}paste into dashboard → Settings → General${colors.reset}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@renbostudios/edge-ota",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "preferGlobal": true,
5
5
  "description": "Zero-SDK, self-hostable OTA update CLI for Expo — push signed JS bundles to any EdgeOTA server",
6
6
  "license": "MIT",