@upfluxhq/cli 0.1.0-beta.2 → 0.1.0-beta.4

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,6 +1,6 @@
1
1
  # @upfluxhq/cli
2
2
 
3
- Command-line interface for managing Upflux OTA updates.
3
+ Command-line interface for managing Upflux OTA updates for React Native and Capacitor apps.
4
4
 
5
5
  ## Installation
6
6
 
@@ -40,6 +40,7 @@ upflux login --key <publishKey>
40
40
 
41
41
  **Example output with multiple logins:**
42
42
  ```
43
+ šŸ” Verifying with server...
43
44
  āœ“ Login successful!
44
45
  App ID: app-970384d3
45
46
  Deployment: production
@@ -78,6 +79,10 @@ upflux logout --all
78
79
 
79
80
  Bundle and upload a new release. **Prompts to select deployment** if multiple are stored.
80
81
 
82
+ Automatically detects project type:
83
+ - **React Native**: Uses `react-native bundle` command
84
+ - **Capacitor**: Zips the web directory (detected via `capacitor.config.*`)
85
+
81
86
  ```bash
82
87
  upflux release [options]
83
88
  ```
@@ -85,8 +90,8 @@ upflux release [options]
85
90
  | Option | Required | Description |
86
91
  |--------|----------|-------------|
87
92
  | `-l, --label <label>` | āœ“ | Release label (e.g., v1.0.0) |
88
- | `--target-platform <platform>` | | Target platform: `all`, `ios`, or `android` (defaults to `all`) |
89
- | `-a, --app <appId>` | | Application ID (auto-detected) |
93
+ | `--platform <platform>` | | Target platform: `all`, `ios`, or `android` (defaults to `all`) |
94
+ | `-a, --app <appId>` | | Application ID (auto-detected from credentials) |
90
95
  | `-d, --deployment <deploymentId>` | | Deployment ID (prompts if multiple) |
91
96
  | `-e, --entry-file <path>` | | Entry file for bundler (default: `index.js`) |
92
97
  | `-o, --output-dir <path>` | | Output directory for bundle (default: `./dist`) |
@@ -97,24 +102,75 @@ upflux release [options]
97
102
  | `-v, --version-range <range>` | | Binary version range (e.g., >=1.0.0) |
98
103
  | `--dev` | | Create development bundle |
99
104
  | `--skip-bundle` | | Skip bundling, use existing files in output dir |
105
+ | `--schedule` | | Create as draft, set schedule time in dashboard |
106
+ | `--capacitor` | | Force Capacitor mode (auto-detected if capacitor.config exists) |
107
+ | `--web-dir <path>` | | Web directory to bundle for Capacitor (default: from config or www) |
100
108
 
101
- **Example with deployment selection:**
102
- ```
103
- $ upflux release --label v1.2.0
109
+ **Examples:**
110
+
111
+ ```bash
112
+ # Release to selected deployment (prompts if multiple)
113
+ upflux release --label v1.2.0
114
+
115
+ # iOS-only hotfix
116
+ upflux release --label v1.2.1-ios-fix --platform ios
104
117
 
105
- ? Select deployment to release to:
106
- āÆ production (app-970384d3)
107
- staging (app-970384d3)
118
+ # Android-only release with 50% rollout
119
+ upflux release --label v1.2.2 --platform android --rollout 50
108
120
 
109
- # For iOS-only hotfix:
110
- $ upflux release --label v1.2.1-ios-fix --target-platform ios
121
+ # Mandatory update for all platforms
122
+ upflux release --label v1.3.0 --mandatory
123
+
124
+ # Schedule release (draft mode)
125
+ upflux release --label v1.4.0 --schedule
126
+
127
+ # Capacitor project with custom web directory
128
+ upflux release --label v1.0.0 --capacitor --web-dir dist
129
+ ```
130
+
131
+ **Example output:**
132
+ ```
133
+ šŸ“¦ Bundling for iOS...
134
+ āœ“ iOS bundle created
135
+ šŸ“¦ Bundling for Android...
136
+ āœ“ Android bundle created
137
+
138
+ ⬆ Uploading release...
139
+ App: app-970384d3
140
+ Deployment: 6933e683aa32fe18a1311bbb
141
+ Deployment Name: production
142
+ Label: v1.2.0
143
+ Platform: all
144
+ Bundle: ./dist/ios/index.ios.bundle
145
+ Assets: 24 file(s)
146
+ Rollout: 100%
147
+ Mandatory: No
148
+
149
+ šŸ“‹ Step 1/3: Requesting upload URLs...
150
+ āœ“ Got 26 presigned URLs
151
+ šŸ“¤ Step 2/3: Uploading files directly to storage...
152
+ [1/26] index.ios.bundle
153
+ ...
154
+ āœ“ Uploaded 26 files
155
+ āœ… Step 3/3: Confirming release...
156
+
157
+ āœ“ Release uploaded successfully!
158
+ ──────────────────────────────────
159
+ ID: 693507a19f6d6a79267633e8
160
+ Label: v1.2.0
161
+ iOS Bundle: uploaded
162
+ Android Bundle: uploaded
163
+ Rollout: 100%
164
+ Mandatory: No
165
+ Status: active
166
+ ──────────────────────────────────
111
167
  ```
112
168
 
113
169
  ---
114
170
 
115
171
  ### `status`
116
172
 
117
- Check release status for an app or deployment.
173
+ Check deployment status and recent releases.
118
174
 
119
175
  ```bash
120
176
  upflux status [options]
@@ -122,9 +178,28 @@ upflux status [options]
122
178
 
123
179
  | Option | Description |
124
180
  |--------|-------------|
125
- | `-a, --app <appId>` | Filter by application ID |
126
- | `-d, --deployment <deploymentId>` | Filter by deployment ID |
127
- | `-n, --limit <number>` | Number of releases to show (default: 10) |
181
+ | `-n, --limit <number>` | Number of releases to show (default: 5) |
182
+
183
+ **Example output:**
184
+ ```
185
+ ⚔ Fetching deployment status...
186
+
187
+ ╭────────────────────────────────────────────────╮
188
+ │ šŸ“± app-970384d3 → production │
189
+ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
190
+ │ Current Release: v1.2.0 (iOS & Android) │
191
+ │ Status: ā— active • 100% rollout │
192
+ │ Published: 2h ago │
193
+ ╰────────────────────────────────────────────────╯
194
+
195
+ šŸ“¦ Recent Releases (15 total)
196
+
197
+ Label Platform Status Rollout Published
198
+ ────────────────────────────────────────────────────────
199
+ v1.2.0 all ā— active 100% 2h ago
200
+ v1.1.0 all ā—‹ expired 100% 3d ago
201
+ v1.0.0 ios ā—‹ expired 100% 1w ago
202
+ ```
128
203
 
129
204
  ---
130
205
 
@@ -141,6 +216,50 @@ upflux generate-manifest [options]
141
216
  | `-r, --release-id <id>` | āœ“ | Release ID (MongoDB ObjectId) |
142
217
  | `-o, --output <path>` | | Save manifest to file |
143
218
 
219
+ **Example:**
220
+ ```bash
221
+ # Print manifest to stdout
222
+ upflux generate-manifest --release-id 693507a19f6d6a79267633e8
223
+
224
+ # Save to file
225
+ upflux generate-manifest --release-id 693507a19f6d6a79267633e8 --output manifest.json
226
+ ```
227
+
228
+ ---
229
+
230
+ ## Scheduled Releases
231
+
232
+ Schedule releases to go live at a specific date and time. Perfect for coordinating updates with marketing campaigns, feature launches, or maintenance windows.
233
+
234
+ ### How It Works
235
+
236
+ 1. Create a release with the `--schedule` flag
237
+ 2. The release is uploaded as a **Draft**
238
+ 3. Set the scheduled time in the Upflux Dashboard
239
+ 4. The release automatically goes live at the scheduled time
240
+
241
+ ### Creating a Scheduled Release
242
+
243
+ ```bash
244
+ # Create a draft release for scheduling
245
+ upflux release --label v2.0.0 --schedule
246
+
247
+ # Output:
248
+ # āœ“ Release uploaded successfully!
249
+ # ...
250
+ # Status: DRAFT
251
+ # šŸ“… Set schedule time in dashboard
252
+ ```
253
+
254
+ ### Use Cases
255
+
256
+ - **Feature launches** - Coordinate with marketing announcements
257
+ - **Global releases** - Release at optimal times for different regions
258
+ - **Maintenance windows** - Deploy during low-traffic periods
259
+ - **Campaign coordination** - Sync with promotional events
260
+
261
+ > šŸ’” **Tip:** Combine scheduled releases with rollout percentages for safer deployments. For example, schedule a 10% rollout first, then manually increase after monitoring.
262
+
144
263
  ---
145
264
 
146
265
  ## Configuration
@@ -149,7 +268,7 @@ upflux generate-manifest [options]
149
268
 
150
269
  | Variable | Description | Default |
151
270
  |----------|-------------|---------|
152
- | `UPFLUX_API_URL` | API server URL | `http://localhost:3000/api` |
271
+ | `UPFLUX_API_URL` | API server URL | `https://api.upflux.in/api` |
153
272
 
154
273
  ### Auth File
155
274
 
@@ -159,13 +278,13 @@ The CLI stores credentials in `.upflux` in the current directory:
159
278
  {
160
279
  "credentials": [
161
280
  {
162
- "key": "pk_xxx...",
281
+ "key": "upflux_pub_xxx...",
163
282
  "appId": "app-970384d3",
164
283
  "deploymentId": "6933e683aa32fe18a1311bbb",
165
284
  "deploymentName": "production"
166
285
  },
167
286
  {
168
- "key": "pk_yyy...",
287
+ "key": "upflux_pub_yyy...",
169
288
  "appId": "app-970384d3",
170
289
  "deploymentId": "6933e683aa32fe18a1311ccc",
171
290
  "deploymentName": "staging"
@@ -179,6 +298,16 @@ The CLI stores credentials in `.upflux` in the current directory:
179
298
 
180
299
  ---
181
300
 
301
+ ## Publish Keys
302
+
303
+ Publish keys are used to authenticate the CLI and can be found in the Upflux dashboard under **Settings → Keys**.
304
+
305
+ Key format: `upflux_pub_<prefix>_<secret>`
306
+
307
+ Example: `upflux_pub_prod_a1b2c3d4e5f6...`
308
+
309
+ ---
310
+
182
311
  ## Development
183
312
 
184
313
  ```bash
@@ -192,6 +321,12 @@ pnpm run build
192
321
  node dist/index.js --help
193
322
  ```
194
323
 
324
+ ## Requirements
325
+
326
+ - Node.js >= 18.0.0
327
+ - For React Native projects: `react-native` CLI installed
328
+ - For Capacitor projects: Built web assets in configured `webDir`
329
+
195
330
  ## License
196
331
 
197
332
  MIT
@@ -9,7 +9,8 @@ const configStore_1 = require("../config/configStore");
9
9
  /**
10
10
  * Default API base URL (can be overridden via environment variable)
11
11
  */
12
- const DEFAULT_BASE_URL = "http://localhost:3000/api";
12
+ // const DEFAULT_BASE_URL = "http://localhost:3000/api";
13
+ const DEFAULT_BASE_URL = "https://api.upflux.in/api";
13
14
  console.log(process.env.UPFLUX_API_URL || DEFAULT_BASE_URL);
14
15
  exports.apiClient = axios_1.default.create({
15
16
  baseURL: process.env.UPFLUX_API_URL || DEFAULT_BASE_URL,
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Direct Upload Module
3
+ *
4
+ * Uploads files directly to R2/S3 using presigned URLs,
5
+ * bypassing Vercel's 4.5MB body size limit.
6
+ */
7
+ interface UploadProgress {
8
+ filename: string;
9
+ loaded: number;
10
+ total: number;
11
+ percent: number;
12
+ }
13
+ /**
14
+ * Upload a single file to a presigned URL
15
+ */
16
+ export declare function uploadToPresignedUrl(filePath: string, presignedUrl: string, contentType?: string, onProgress?: (progress: UploadProgress) => void): Promise<void>;
17
+ interface FileManifestItem {
18
+ type: "bundle" | "asset";
19
+ platform?: "ios" | "android";
20
+ filename: string;
21
+ relativePath?: string;
22
+ localPath: string;
23
+ contentType?: string;
24
+ size?: number;
25
+ }
26
+ interface PresignedUrlResponse {
27
+ key: string;
28
+ uploadUrl: string;
29
+ publicUrl: string;
30
+ expiresIn: number;
31
+ }
32
+ /**
33
+ * Upload multiple files in parallel using presigned URLs
34
+ */
35
+ export declare function uploadFilesWithPresignedUrls(files: FileManifestItem[], urls: PresignedUrlResponse[], options?: {
36
+ concurrency?: number;
37
+ onFileComplete?: (filename: string, index: number, total: number) => void;
38
+ onProgress?: (progress: UploadProgress) => void;
39
+ }): Promise<void>;
40
+ /**
41
+ * Guess content type from filename
42
+ */
43
+ export declare function guessContentType(filename: string): string;
44
+ /**
45
+ * Build file manifest from local bundle and asset paths
46
+ */
47
+ export declare function buildFileManifest(platform: "ios" | "android" | "all", bundlePath?: string, assetFiles?: string[], iosBundlePath?: string, androidBundlePath?: string, iosAssetFiles?: string[], androidAssetFiles?: string[], assetsBaseDir?: string, iosAssetsBaseDir?: string, androidAssetsBaseDir?: string): FileManifestItem[];
48
+ export {};
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ /**
3
+ * Direct Upload Module
4
+ *
5
+ * Uploads files directly to R2/S3 using presigned URLs,
6
+ * bypassing Vercel's 4.5MB body size limit.
7
+ */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ var __importDefault = (this && this.__importDefault) || function (mod) {
42
+ return (mod && mod.__esModule) ? mod : { "default": mod };
43
+ };
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.uploadToPresignedUrl = uploadToPresignedUrl;
46
+ exports.uploadFilesWithPresignedUrls = uploadFilesWithPresignedUrls;
47
+ exports.guessContentType = guessContentType;
48
+ exports.buildFileManifest = buildFileManifest;
49
+ const fs = __importStar(require("fs"));
50
+ const path = __importStar(require("path"));
51
+ const axios_1 = __importDefault(require("axios"));
52
+ /**
53
+ * Upload a single file to a presigned URL
54
+ */
55
+ async function uploadToPresignedUrl(filePath, presignedUrl, contentType = "application/octet-stream", onProgress) {
56
+ const stats = fs.statSync(filePath);
57
+ const fileStream = fs.createReadStream(filePath);
58
+ const filename = path.basename(filePath);
59
+ await axios_1.default.put(presignedUrl, fileStream, {
60
+ headers: {
61
+ "Content-Type": contentType,
62
+ "Content-Length": stats.size,
63
+ },
64
+ maxContentLength: Infinity,
65
+ maxBodyLength: Infinity,
66
+ onUploadProgress: (progressEvent) => {
67
+ if (onProgress && progressEvent.total) {
68
+ onProgress({
69
+ filename,
70
+ loaded: progressEvent.loaded,
71
+ total: progressEvent.total,
72
+ percent: Math.round((progressEvent.loaded / progressEvent.total) * 100),
73
+ });
74
+ }
75
+ },
76
+ });
77
+ }
78
+ /**
79
+ * Upload multiple files in parallel using presigned URLs
80
+ */
81
+ async function uploadFilesWithPresignedUrls(files, urls, options) {
82
+ const { concurrency = 3, onFileComplete, onProgress } = options || {};
83
+ // Create a map of relativePath (or filename) to URL
84
+ // This handles duplicate filenames in different folders (e.g., drawable-mdpi/logo.png vs drawable-xhdpi/logo.png)
85
+ const urlMap = new Map();
86
+ for (const url of urls) {
87
+ // Extract relativePath from key (everything after release path)
88
+ // Key format: releases/appId/deployment/release/assets/drawable-mdpi/logo.png
89
+ const parts = url.key.split("/");
90
+ // Find where assets/ or android/ or ios/ starts to get the relativePath
91
+ let relativePath = parts[parts.length - 1]; // fallback to filename
92
+ for (let i = 0; i < parts.length; i++) {
93
+ if (parts[i] === 'assets' || parts[i] === 'android' || parts[i] === 'ios') {
94
+ relativePath = parts.slice(i).join('/');
95
+ break;
96
+ }
97
+ }
98
+ urlMap.set(relativePath, url);
99
+ }
100
+ // Upload files in batches
101
+ let completed = 0;
102
+ const total = files.length;
103
+ const uploadFile = async (file) => {
104
+ // Use relativePath for lookup if available, fallback to filename
105
+ const lookupKey = file.relativePath || file.filename;
106
+ const urlInfo = urlMap.get(lookupKey);
107
+ if (!urlInfo) {
108
+ throw new Error(`No presigned URL found for file: ${lookupKey}`);
109
+ }
110
+ await uploadToPresignedUrl(file.localPath, urlInfo.uploadUrl, file.contentType, onProgress);
111
+ completed++;
112
+ if (onFileComplete) {
113
+ onFileComplete(file.filename, completed, total);
114
+ }
115
+ };
116
+ // Process in batches for controlled concurrency
117
+ for (let i = 0; i < files.length; i += concurrency) {
118
+ const batch = files.slice(i, i + concurrency);
119
+ await Promise.all(batch.map(uploadFile));
120
+ }
121
+ }
122
+ /**
123
+ * Guess content type from filename
124
+ */
125
+ function guessContentType(filename) {
126
+ var _a;
127
+ const ext = (_a = filename.split(".").pop()) === null || _a === void 0 ? void 0 : _a.toLowerCase();
128
+ const contentTypes = {
129
+ bundle: "application/javascript",
130
+ js: "application/javascript",
131
+ json: "application/json",
132
+ zip: "application/zip",
133
+ png: "image/png",
134
+ jpg: "image/jpeg",
135
+ jpeg: "image/jpeg",
136
+ gif: "image/gif",
137
+ svg: "image/svg+xml",
138
+ webp: "image/webp",
139
+ ttf: "font/ttf",
140
+ woff: "font/woff",
141
+ woff2: "font/woff2",
142
+ css: "text/css",
143
+ html: "text/html",
144
+ };
145
+ return contentTypes[ext || ""] || "application/octet-stream";
146
+ }
147
+ /**
148
+ * Build file manifest from local bundle and asset paths
149
+ */
150
+ function buildFileManifest(platform, bundlePath, assetFiles, iosBundlePath, androidBundlePath, iosAssetFiles, androidAssetFiles,
151
+ // Base directories for calculating relative paths
152
+ assetsBaseDir, iosAssetsBaseDir, androidAssetsBaseDir) {
153
+ const files = [];
154
+ if (platform === "all") {
155
+ // Dual-bundle mode
156
+ if (iosBundlePath) {
157
+ const stats = fs.statSync(iosBundlePath);
158
+ files.push({
159
+ type: "bundle",
160
+ platform: "ios",
161
+ filename: path.basename(iosBundlePath),
162
+ localPath: iosBundlePath,
163
+ contentType: "application/javascript",
164
+ size: stats.size,
165
+ });
166
+ }
167
+ if (androidBundlePath) {
168
+ const stats = fs.statSync(androidBundlePath);
169
+ files.push({
170
+ type: "bundle",
171
+ platform: "android",
172
+ filename: path.basename(androidBundlePath),
173
+ localPath: androidBundlePath,
174
+ contentType: "application/javascript",
175
+ size: stats.size,
176
+ });
177
+ }
178
+ for (const assetPath of iosAssetFiles || []) {
179
+ // Calculate relative path from assets base directory
180
+ const relativePath = iosAssetsBaseDir
181
+ ? path.relative(iosAssetsBaseDir, assetPath)
182
+ : path.basename(assetPath);
183
+ files.push({
184
+ type: "asset",
185
+ platform: "ios",
186
+ filename: path.basename(assetPath),
187
+ relativePath,
188
+ localPath: assetPath,
189
+ contentType: guessContentType(assetPath),
190
+ });
191
+ }
192
+ for (const assetPath of androidAssetFiles || []) {
193
+ // Calculate relative path from assets base directory
194
+ const relativePath = androidAssetsBaseDir
195
+ ? path.relative(androidAssetsBaseDir, assetPath)
196
+ : path.basename(assetPath);
197
+ files.push({
198
+ type: "asset",
199
+ platform: "android",
200
+ filename: path.basename(assetPath),
201
+ relativePath,
202
+ localPath: assetPath,
203
+ contentType: guessContentType(assetPath),
204
+ });
205
+ }
206
+ }
207
+ else {
208
+ // Single-bundle mode
209
+ if (bundlePath) {
210
+ const stats = fs.statSync(bundlePath);
211
+ files.push({
212
+ type: "bundle",
213
+ filename: path.basename(bundlePath),
214
+ localPath: bundlePath,
215
+ contentType: "application/javascript",
216
+ size: stats.size,
217
+ });
218
+ }
219
+ for (const assetPath of assetFiles || []) {
220
+ // Calculate relative path from assets base directory
221
+ const relativePath = assetsBaseDir
222
+ ? path.relative(assetsBaseDir, assetPath)
223
+ : path.basename(assetPath);
224
+ files.push({
225
+ type: "asset",
226
+ filename: path.basename(assetPath),
227
+ relativePath,
228
+ localPath: assetPath,
229
+ contentType: guessContentType(assetPath),
230
+ });
231
+ }
232
+ }
233
+ return files;
234
+ }
@@ -40,7 +40,6 @@ const commander_1 = require("commander");
40
40
  const fs = __importStar(require("fs"));
41
41
  const path = __importStar(require("path"));
42
42
  const child_process_1 = require("child_process");
43
- const form_data_1 = __importDefault(require("form-data"));
44
43
  const chalk_1 = __importDefault(require("chalk"));
45
44
  const inquirer_1 = __importDefault(require("inquirer"));
46
45
  const archiver_1 = __importDefault(require("archiver"));
@@ -215,7 +214,6 @@ function runBundler(options) {
215
214
  if (!fs.existsSync(outputDir)) {
216
215
  fs.mkdirSync(outputDir, { recursive: true });
217
216
  }
218
- console.log(chalk_1.default.blue(`šŸ“¦ Bundling for ${platform}...`));
219
217
  const command = [
220
218
  "npx react-native bundle",
221
219
  `--platform ${platform}`,
@@ -256,6 +254,27 @@ function collectAssets(dir) {
256
254
  }
257
255
  return assets;
258
256
  }
257
+ /**
258
+ * Write upflux.json config file to the bundle output directory
259
+ *
260
+ * This file embeds the OTA version into the app bundle at build time.
261
+ * The SDK reads this on fresh install to know which version the app ships with,
262
+ * preventing unnecessary OTA downloads when the embedded bundle is already latest.
263
+ *
264
+ * @param outputDir - Directory to write upflux.json to
265
+ * @param releaseLabel - The OTA version (e.g., "v1.0.0")
266
+ * @param appId - The app ID
267
+ */
268
+ function writeUpfluxConfig(outputDir, releaseLabel, appId) {
269
+ const config = {
270
+ releaseLabel,
271
+ appId,
272
+ createdAt: new Date().toISOString(),
273
+ };
274
+ const configPath = path.join(outputDir, "upflux.json");
275
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
276
+ console.log(chalk_1.default.gray(` Created upflux.json with version: ${releaseLabel}`));
277
+ }
259
278
  const release = new commander_1.Command("release")
260
279
  .description("Bundle and upload a new release to Upflux")
261
280
  .option("-a, --app <appId>", "Application ID (uses stored value if not provided)")
@@ -312,6 +331,11 @@ const release = new commander_1.Command("release")
312
331
  else {
313
332
  // Multiple credentials - prompt for selection
314
333
  selectedCredential = await selectDeployment(credentials);
334
+ // Update activeIndex so subsequent commands use the same deployment
335
+ const selectedIndex = credentials.findIndex(c => c.deploymentId === selectedCredential.deploymentId);
336
+ if (selectedIndex >= 0) {
337
+ configStore_1.configStore.setActiveIndex(selectedIndex);
338
+ }
315
339
  }
316
340
  const appId = selectedCredential.appId;
317
341
  const deploymentId = selectedCredential.deploymentId;
@@ -369,6 +393,11 @@ const release = new commander_1.Command("release")
369
393
  let androidBundlePath;
370
394
  let iosAssetFiles = [];
371
395
  let androidAssetFiles = [];
396
+ // Asset directories for calculating relative paths (preserves drawable-* structure)
397
+ // These point to the platform output directory (e.g., dist/android/) so paths include assets/
398
+ let assetsBaseDir;
399
+ let iosAssetsBaseDir;
400
+ let androidAssetsBaseDir;
372
401
  // Detect project type
373
402
  const isCapacitor = opts.capacitor || isCapacitorProject();
374
403
  if (isCapacitor) {
@@ -396,6 +425,9 @@ const release = new commander_1.Command("release")
396
425
  if (!fs.existsSync(outputDir)) {
397
426
  fs.mkdirSync(outputDir, { recursive: true });
398
427
  }
428
+ // Write upflux.json to web directory BEFORE zipping
429
+ // This embeds the OTA version into the Capacitor app bundle
430
+ writeUpfluxConfig(webDirPath, opts.label, appId);
399
431
  // Zip the web directory
400
432
  console.log(chalk_1.default.blue(`šŸ“¦ Zipping ${webDir}/ directory...`));
401
433
  const zipPath = path.join(outputDir, `bundle-${opts.label}.zip`);
@@ -484,16 +516,24 @@ const release = new commander_1.Command("release")
484
516
  process.exit(1);
485
517
  }
486
518
  }
487
- // Collect assets for iOS
488
- const iosAssetsDir = path.join(path.dirname(iosBundlePath), 'assets');
519
+ // Collect assets for iOS - base dir is the ios output dir so paths include assets/
520
+ const iosOutputDir = path.dirname(iosBundlePath);
521
+ iosAssetsBaseDir = iosOutputDir;
522
+ const iosAssetsPath = path.join(iosOutputDir, 'assets');
489
523
  iosAssetFiles = opts.assets
490
524
  ? opts.assets.map((p) => path.resolve(p))
491
- : collectAssets(iosAssetsDir);
492
- // Collect assets for Android
493
- const androidAssetsDir = path.join(path.dirname(androidBundlePath), 'assets');
525
+ : collectAssets(iosAssetsPath);
526
+ // Collect assets for Android - base dir is the android output dir so paths include assets/
527
+ const androidOutputDir = path.dirname(androidBundlePath);
528
+ androidAssetsBaseDir = androidOutputDir;
529
+ const androidAssetsPath = path.join(androidOutputDir, 'assets');
494
530
  androidAssetFiles = opts.assets
495
531
  ? opts.assets.map((p) => path.resolve(p))
496
- : collectAssets(androidAssetsDir);
532
+ : collectAssets(androidAssetsPath);
533
+ // Write upflux.json to BOTH platform output directories
534
+ // React Native reads this from bundled assets to know embedded OTA version
535
+ writeUpfluxConfig(iosOutputDir, opts.label, appId);
536
+ writeUpfluxConfig(androidOutputDir, opts.label, appId);
497
537
  // Set bundlePath and assetFiles for display (use iOS as primary for logging)
498
538
  bundlePath = iosBundlePath;
499
539
  assetFiles = [...iosAssetFiles, ...androidAssetFiles];
@@ -503,7 +543,9 @@ const release = new commander_1.Command("release")
503
543
  // SINGLE PLATFORM BUNDLING
504
544
  // ========================================
505
545
  const expectedBundle = path.join(outputDir, `index.${platform}.bundle`);
506
- const assetsDir = path.join(outputDir, "assets");
546
+ // Base dir is outputDir so relative paths include assets/ folder
547
+ assetsBaseDir = outputDir;
548
+ const assetsPath = path.join(outputDir, "assets");
507
549
  if (opts.skipBundle) {
508
550
  if (!fs.existsSync(expectedBundle)) {
509
551
  console.error(chalk_1.default.red(`āœ— Bundle not found: ${expectedBundle}`));
@@ -526,7 +568,7 @@ const release = new commander_1.Command("release")
526
568
  assetFiles = opts.assets.map((p) => path.resolve(p));
527
569
  }
528
570
  else {
529
- assetFiles = collectAssets(assetsDir);
571
+ assetFiles = collectAssets(assetsPath);
530
572
  }
531
573
  // Validate asset files
532
574
  const invalidAssets = assetFiles.filter(p => !fs.existsSync(p));
@@ -534,6 +576,9 @@ const release = new commander_1.Command("release")
534
576
  console.warn(chalk_1.default.yellow(`⚠ ${invalidAssets.length} asset(s) not found, will be skipped`));
535
577
  assetFiles = assetFiles.filter(p => fs.existsSync(p));
536
578
  }
579
+ // Write upflux.json to output directory
580
+ // React Native reads this from bundled assets to know embedded OTA version
581
+ writeUpfluxConfig(outputDir, opts.label, appId);
537
582
  }
538
583
  }
539
584
  // ========================================
@@ -555,71 +600,114 @@ const release = new commander_1.Command("release")
555
600
  if (opts.versionRange) {
556
601
  console.log(` Version Range: ${opts.versionRange}`);
557
602
  }
558
- // Create form data
559
- const form = new form_data_1.default();
560
- if (platform === 'all') {
561
- // Upload both iOS and Android bundles
562
- form.append("bundle_ios", fs.createReadStream(iosBundlePath));
563
- form.append("bundle_android", fs.createReadStream(androidBundlePath));
564
- // Upload iOS assets
565
- for (const assetPath of iosAssetFiles) {
566
- form.append("assets_ios", fs.createReadStream(assetPath));
567
- }
568
- // Upload Android assets
569
- for (const assetPath of androidAssetFiles) {
570
- form.append("assets_android", fs.createReadStream(assetPath));
571
- }
572
- }
573
- else {
574
- // Single platform upload
575
- form.append("bundle", fs.createReadStream(bundlePath));
576
- // Add asset files
577
- for (const assetPath of assetFiles) {
578
- form.append("assets", fs.createReadStream(assetPath));
603
+ // ========================================
604
+ // PRESIGNED URL UPLOAD FLOW
605
+ // ========================================
606
+ // This flow bypasses Vercel's 4.5MB body size limit by:
607
+ // 1. Requesting presigned URLs from the API
608
+ // 2. Uploading files directly to R2
609
+ // 3. Confirming upload completion
610
+ const { buildFileManifest, uploadFilesWithPresignedUrls } = await Promise.resolve().then(() => __importStar(require("../client/directUpload")));
611
+ // Build file manifest for presigned URL request
612
+ const fileManifest = buildFileManifest(platform, platform !== 'all' ? bundlePath : undefined, platform !== 'all' ? assetFiles : undefined, platform === 'all' ? iosBundlePath : undefined, platform === 'all' ? androidBundlePath : undefined, platform === 'all' ? iosAssetFiles : undefined, platform === 'all' ? androidAssetFiles : undefined,
613
+ // Asset base directories for relative path calculation
614
+ platform !== 'all' ? assetsBaseDir : undefined, platform === 'all' ? iosAssetsBaseDir : undefined, platform === 'all' ? androidAssetsBaseDir : undefined);
615
+ console.log(chalk_1.default.blue("\nšŸ“‹ Step 1/3: Requesting upload URLs..."));
616
+ // Step 1: Request presigned URLs
617
+ const initResponse = await apiClient_1.apiClient.post("/cli/releases/upload/init", {
618
+ appId,
619
+ deploymentId,
620
+ releaseLabel: opts.label,
621
+ platform,
622
+ files: fileManifest.map(f => ({
623
+ type: f.type,
624
+ platform: f.platform,
625
+ filename: f.filename,
626
+ relativePath: f.relativePath, // Preserves folder structure like drawable-mdpi/logo.png
627
+ contentType: f.contentType,
628
+ })),
629
+ }, {
630
+ headers: {
631
+ "x-publish-key": authKey,
632
+ "Content-Type": "application/json",
633
+ },
634
+ });
635
+ if (!initResponse.data.success) {
636
+ throw new Error("Failed to initialize upload");
637
+ }
638
+ console.log(chalk_1.default.green(` āœ“ Got ${initResponse.data.urls.length} presigned URLs`));
639
+ console.log(chalk_1.default.blue("\nšŸ“¤ Step 2/3: Uploading files directly to storage..."));
640
+ // Step 2: Upload files directly to R2
641
+ let uploadedCount = 0;
642
+ await uploadFilesWithPresignedUrls(fileManifest, initResponse.data.urls, {
643
+ concurrency: 3,
644
+ onFileComplete: (filename, index, total) => {
645
+ uploadedCount++;
646
+ console.log(chalk_1.default.gray(` [${uploadedCount}/${total}] ${filename}`));
647
+ },
648
+ });
649
+ console.log(chalk_1.default.green(` āœ“ Uploaded ${fileManifest.length} files`));
650
+ console.log(chalk_1.default.blue("\nāœ… Step 3/3: Confirming release..."));
651
+ // Calculate bundle sizes from manifest
652
+ const bundleSizes = {};
653
+ for (const file of fileManifest) {
654
+ if (file.type === 'bundle' && file.size) {
655
+ if (file.platform === 'ios') {
656
+ bundleSizes.ios = file.size;
657
+ }
658
+ else if (file.platform === 'android') {
659
+ bundleSizes.android = file.size;
660
+ }
661
+ else {
662
+ bundleSizes.single = file.size;
663
+ }
579
664
  }
580
665
  }
581
- // Common fields
582
- form.append("releaseLabel", opts.label);
583
- form.append("appId", appId);
584
- form.append("deploymentId", deploymentId);
585
- form.append("deploymentName", deploymentName);
586
- form.append("mandatory", String(opts.mandatory));
587
- form.append("rolloutPercentage", String(rolloutValidation.value));
588
- if (opts.schedule) {
589
- form.append("isDraft", "true");
590
- }
591
- // Add platform (always include, even for 'all')
592
- form.append("platform", platform);
593
- if (opts.versionRange) {
594
- form.append("binaryVersionRange", opts.versionRange);
595
- }
596
- // Upload to API (using selected credential's key)
597
- const response = await apiClient_1.apiClient.post("/releases", form, {
598
- headers: Object.assign(Object.assign({}, form.getHeaders()), { "x-publish-key": authKey }),
666
+ // Step 3: Complete upload and create release record
667
+ const completeResponse = await apiClient_1.apiClient.post("/cli/releases/upload/complete", {
668
+ uploadId: initResponse.data.uploadId,
669
+ releaseLabel: opts.label,
670
+ mandatory: opts.mandatory,
671
+ rolloutPercentage: rolloutValidation.value,
672
+ binaryVersionRange: opts.versionRange,
673
+ platform,
674
+ isDraft: opts.schedule,
675
+ // Include bundle sizes
676
+ iosBundleSize: bundleSizes.ios,
677
+ androidBundleSize: bundleSizes.android || bundleSizes.single,
678
+ }, {
679
+ headers: {
680
+ "x-publish-key": authKey,
681
+ "Content-Type": "application/json",
682
+ },
599
683
  });
684
+ if (!completeResponse.data.success) {
685
+ throw new Error("Failed to complete upload");
686
+ }
687
+ const releaseData = completeResponse.data.data;
600
688
  console.log(chalk_1.default.green("\nāœ“ Release uploaded successfully!"));
601
689
  console.log(chalk_1.default.gray("──────────────────────────────────"));
602
- console.log(` ID: ${response.data.id}`);
603
- console.log(` Label: ${response.data.releaseLabel}`);
690
+ console.log(` ID: ${releaseData.id}`);
691
+ console.log(` Label: ${releaseData.releaseLabel}`);
604
692
  // Display bundle URLs based on platform
605
693
  if (platform === 'all') {
606
- console.log(` iOS Bundle: ${response.data.iosBundleUrl || 'uploaded'}`);
607
- console.log(` Android Bundle: ${response.data.androidBundleUrl || 'uploaded'}`);
694
+ console.log(` iOS Bundle: ${releaseData.iosBundleUrl || 'uploaded'}`);
695
+ console.log(` Android Bundle: ${releaseData.androidBundleUrl || 'uploaded'}`);
608
696
  }
609
697
  else {
610
- console.log(` Bundle URL: ${response.data.bundleUrl || response.data.iosBundleUrl || response.data.androidBundleUrl}`);
698
+ console.log(` Bundle URL: ${releaseData.bundleUrl || releaseData.iosBundleUrl || releaseData.androidBundleUrl}`);
611
699
  }
612
- console.log(` Rollout: ${response.data.rolloutPercentage}%`);
613
- console.log(` Mandatory: ${response.data.mandatory ? "Yes" : "No"}`);
614
- if (response.data.status === 'draft') {
700
+ console.log(` Rollout: ${releaseData.rolloutPercentage}%`);
701
+ console.log(` Mandatory: ${releaseData.mandatory ? "Yes" : "No"}`);
702
+ if (releaseData.status === 'draft') {
615
703
  console.log(chalk_1.default.yellow(` Status: DRAFT`));
616
704
  console.log(chalk_1.default.gray(` šŸ“… Set schedule time in dashboard`));
617
705
  }
618
706
  else {
619
- console.log(` Status: ${response.data.status || 'active'}`);
707
+ console.log(` Status: ${releaseData.status || 'active'}`);
620
708
  }
621
- if (response.data.binaryVersionRange) {
622
- console.log(` Version Range: ${response.data.binaryVersionRange}`);
709
+ if (releaseData.binaryVersionRange) {
710
+ console.log(` Version Range: ${releaseData.binaryVersionRange}`);
623
711
  }
624
712
  console.log(chalk_1.default.gray("──────────────────────────────────"));
625
713
  }
@@ -5,85 +5,189 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const commander_1 = require("commander");
7
7
  const chalk_1 = __importDefault(require("chalk"));
8
+ const inquirer_1 = __importDefault(require("inquirer"));
8
9
  const apiClient_1 = require("../client/apiClient");
10
+ const configStore_1 = require("../config/configStore");
9
11
  /**
10
- * Validate MongoDB ObjectId format
12
+ * Format relative time (e.g., "2 hours ago")
11
13
  */
12
- function isValidObjectId(id) {
13
- return /^[a-fA-F0-9]{24}$/.test(id);
14
+ function formatRelativeTime(dateStr) {
15
+ const date = new Date(dateStr);
16
+ const now = new Date();
17
+ const diffMs = now.getTime() - date.getTime();
18
+ const diffSecs = Math.floor(diffMs / 1000);
19
+ const diffMins = Math.floor(diffSecs / 60);
20
+ const diffHours = Math.floor(diffMins / 60);
21
+ const diffDays = Math.floor(diffHours / 24);
22
+ if (diffDays > 0)
23
+ return `${diffDays}d ago`;
24
+ if (diffHours > 0)
25
+ return `${diffHours}h ago`;
26
+ if (diffMins > 0)
27
+ return `${diffMins}m ago`;
28
+ return "just now";
14
29
  }
15
30
  /**
16
- * Validate app ID format (alphanumeric with hyphens)
31
+ * Select deployment from multiple credentials
17
32
  */
18
- function isValidAppId(appId) {
19
- return /^[a-zA-Z0-9-_]+$/.test(appId) && appId.length >= 3;
33
+ async function selectDeployment(credentials) {
34
+ if (credentials.length === 1) {
35
+ return credentials[0];
36
+ }
37
+ const choices = credentials.map((cred) => ({
38
+ name: `${cred.deploymentName || cred.deploymentId} (${cred.appId})`,
39
+ value: cred,
40
+ }));
41
+ const { selected } = await inquirer_1.default.prompt([
42
+ {
43
+ type: "list",
44
+ name: "selected",
45
+ message: "Select deployment to check status:",
46
+ choices,
47
+ }
48
+ ]);
49
+ return selected;
50
+ }
51
+ /**
52
+ * Draw a box around text
53
+ */
54
+ function drawBox(lines, width = 50) {
55
+ const output = [];
56
+ const border = "─".repeat(width - 2);
57
+ output.push(chalk_1.default.gray(`ā•­${border}ā•®`));
58
+ for (const line of lines) {
59
+ // Strip ANSI codes for length calculation
60
+ const plainLine = line.replace(/\x1b\[[0-9;]*m/g, "");
61
+ const padding = width - 4 - plainLine.length;
62
+ output.push(chalk_1.default.gray("│") + " " + line + " ".repeat(Math.max(0, padding)) + chalk_1.default.gray("│"));
63
+ }
64
+ output.push(chalk_1.default.gray(`ā•°${border}╯`));
65
+ return output;
66
+ }
67
+ /**
68
+ * Get status indicator
69
+ */
70
+ function getStatusIndicator(status) {
71
+ switch (status) {
72
+ case "active":
73
+ return chalk_1.default.green("ā—");
74
+ case "scheduled":
75
+ return chalk_1.default.yellow("◐");
76
+ case "expired":
77
+ return chalk_1.default.gray("ā—‹");
78
+ case "draft":
79
+ return chalk_1.default.blue("ā—‘");
80
+ default:
81
+ return chalk_1.default.gray("ā—‹");
82
+ }
20
83
  }
21
84
  const status = new commander_1.Command("status")
22
- .description("Check app release status")
23
- .option("-a, --app <appId>", "Filter by application ID")
24
- .option("-d, --deployment <deploymentId>", "Filter by deployment ID")
25
- .option("-n, --limit <number>", "Number of releases to show", "10")
85
+ .description("Check deployment status and releases")
86
+ .option("-n, --limit <number>", "Number of releases to show", "5")
26
87
  .action(async (opts) => {
27
88
  try {
28
- // Validation 1: App ID format (if provided)
29
- if (opts.app && !isValidAppId(opts.app)) {
30
- console.error(chalk_1.default.red("āœ— Invalid app ID format"));
31
- console.error(chalk_1.default.gray("App ID should be alphanumeric with hyphens, minimum 3 characters"));
89
+ // Check for credentials
90
+ const credentials = configStore_1.configStore.getCredentials();
91
+ if (credentials.length === 0) {
92
+ console.error(chalk_1.default.red("āœ— Not authenticated"));
93
+ console.error(chalk_1.default.gray("Run 'upflux login --key <publishKey>' first."));
32
94
  process.exit(1);
33
95
  }
34
- // Validation 2: Deployment ID format (if provided)
35
- if (opts.deployment && !isValidObjectId(opts.deployment)) {
36
- console.error(chalk_1.default.red("āœ— Invalid deployment ID format"));
37
- console.error(chalk_1.default.gray("Deployment ID should be a 24-character MongoDB ObjectId"));
38
- process.exit(1);
96
+ // Select or determine credential to use
97
+ let selectedCredential;
98
+ if (credentials.length === 1) {
99
+ selectedCredential = credentials[0];
39
100
  }
40
- // Validation 3: Limit is a positive number
41
- const limit = parseInt(opts.limit, 10);
42
- if (isNaN(limit) || limit < 1 || limit > 100) {
43
- console.error(chalk_1.default.red("āœ— Invalid limit value"));
44
- console.error(chalk_1.default.gray("Limit should be a number between 1 and 100"));
45
- process.exit(1);
101
+ else {
102
+ selectedCredential = await selectDeployment(credentials);
103
+ // Update activeIndex
104
+ const selectedIndex = credentials.findIndex(c => c.deploymentId === selectedCredential.deploymentId);
105
+ if (selectedIndex >= 0) {
106
+ configStore_1.configStore.setActiveIndex(selectedIndex);
107
+ }
46
108
  }
47
- console.log(chalk_1.default.blue("⚔ Fetching releases...\n"));
48
- // Build query params
49
- const params = {};
50
- if (opts.app)
51
- params.appId = opts.app;
52
- if (opts.deployment)
53
- params.deploymentId = opts.deployment;
54
- const response = await apiClient_1.apiClient.get("/releases", { params });
55
- const releases = response.data.releases;
56
- if (releases.length === 0) {
57
- console.log(chalk_1.default.yellow("No releases found."));
58
- if (opts.app || opts.deployment) {
59
- console.log(chalk_1.default.gray("Try removing filters to see all releases."));
109
+ console.log(chalk_1.default.blue("\n⚔ Fetching deployment status...\n"));
110
+ // Fetch status from API
111
+ const response = await apiClient_1.apiClient.get("/cli/status");
112
+ const data = response.data;
113
+ // ========================================
114
+ // DEPLOYMENT HEADER
115
+ // ========================================
116
+ const headerLines = [];
117
+ headerLines.push(chalk_1.default.bold(`šŸ“± ${data.deployment.appId} → ${data.deployment.name}`));
118
+ headerLines.push(chalk_1.default.gray("━".repeat(40)));
119
+ // Show current releases
120
+ if (data.current.ios || data.current.android) {
121
+ if (data.current.ios && data.current.android) {
122
+ // Dual platform
123
+ if (data.current.ios.releaseLabel === data.current.android.releaseLabel) {
124
+ // Same version for both
125
+ headerLines.push(`Current Release: ${chalk_1.default.cyan(data.current.ios.releaseLabel)} ${chalk_1.default.gray("(iOS & Android)")}`);
126
+ headerLines.push(`Status: ${getStatusIndicator(data.current.ios.status)} ${data.current.ios.status} • ${data.current.ios.rolloutPercentage}% rollout`);
127
+ headerLines.push(`Published: ${formatRelativeTime(data.current.ios.publishedAt)}`);
128
+ }
129
+ else {
130
+ // Different versions
131
+ headerLines.push(`iOS: ${chalk_1.default.cyan(data.current.ios.releaseLabel)} ${getStatusIndicator(data.current.ios.status)} ${data.current.ios.rolloutPercentage}%`);
132
+ headerLines.push(`Android: ${chalk_1.default.cyan(data.current.android.releaseLabel)} ${getStatusIndicator(data.current.android.status)} ${data.current.android.rolloutPercentage}%`);
133
+ }
134
+ }
135
+ else if (data.current.ios) {
136
+ headerLines.push(`iOS: ${chalk_1.default.cyan(data.current.ios.releaseLabel)} ${getStatusIndicator(data.current.ios.status)}`);
137
+ headerLines.push(`${data.current.ios.rolloutPercentage}% rollout • ${formatRelativeTime(data.current.ios.publishedAt)}`);
138
+ }
139
+ else if (data.current.android) {
140
+ headerLines.push(`Android: ${chalk_1.default.cyan(data.current.android.releaseLabel)} ${getStatusIndicator(data.current.android.status)}`);
141
+ headerLines.push(`${data.current.android.rolloutPercentage}% rollout • ${formatRelativeTime(data.current.android.publishedAt)}`);
60
142
  }
61
- return;
62
143
  }
63
- // Limit results
64
- const displayReleases = releases.slice(0, limit);
65
- console.log(chalk_1.default.white.bold(`Found ${releases.length} release(s)${releases.length > limit ? ` (showing ${limit})` : ""}:\n`));
66
- // Display each release
67
- displayReleases.forEach((release, index) => {
68
- const mandatoryTag = release.mandatory ? chalk_1.default.red(" [MANDATORY]") : "";
69
- const rolloutTag = release.rolloutPercentage < 100
70
- ? chalk_1.default.yellow(` (${release.rolloutPercentage}% rollout)`)
71
- : "";
72
- console.log(chalk_1.default.cyan(`${index + 1}. ${release.releaseLabel}`) + mandatoryTag + rolloutTag);
73
- console.log(chalk_1.default.gray(` ID: ${release.id}`));
74
- console.log(chalk_1.default.gray(` App: ${release.appId} | Deployment: ${release.deploymentId}`));
75
- console.log(chalk_1.default.gray(` Created: ${new Date(release.createdAt).toLocaleString()}`));
76
- if (release.binaryVersionRange) {
77
- console.log(chalk_1.default.gray(` Version Range: ${release.binaryVersionRange}`));
144
+ else {
145
+ headerLines.push(chalk_1.default.yellow("No active release"));
146
+ }
147
+ // Draw the header box
148
+ const headerBox = drawBox(headerLines, 52);
149
+ headerBox.forEach(line => console.log(line));
150
+ // ========================================
151
+ // RELEASES TABLE
152
+ // ========================================
153
+ const limit = parseInt(opts.limit, 10) || 5;
154
+ const releases = data.releases.slice(0, limit);
155
+ if (releases.length > 0) {
156
+ console.log(chalk_1.default.bold(`\nšŸ“¦ Recent Releases (${data.totalReleases} total)\n`));
157
+ // Table header
158
+ console.log(chalk_1.default.gray(" ") +
159
+ chalk_1.default.white.bold("Label".padEnd(12)) +
160
+ chalk_1.default.white.bold("Platform".padEnd(10)) +
161
+ chalk_1.default.white.bold("Status".padEnd(12)) +
162
+ chalk_1.default.white.bold("Rollout".padEnd(10)) +
163
+ chalk_1.default.white.bold("Published"));
164
+ console.log(chalk_1.default.gray(" " + "─".repeat(60)));
165
+ // Table rows
166
+ releases.forEach((release, index) => {
167
+ const statusIcon = getStatusIndicator(release.status);
168
+ const statusText = release.status.padEnd(10);
169
+ const platformText = release.platform.padEnd(10);
170
+ const rolloutText = `${release.rolloutPercentage}%`.padEnd(10);
171
+ const mandatoryFlag = release.mandatory ? chalk_1.default.red(" !") : "";
172
+ console.log(chalk_1.default.gray(" ") +
173
+ chalk_1.default.cyan(release.releaseLabel.padEnd(12)) +
174
+ chalk_1.default.gray(platformText) +
175
+ statusIcon + " " + chalk_1.default.white(statusText) +
176
+ chalk_1.default.yellow(rolloutText) +
177
+ chalk_1.default.gray(formatRelativeTime(release.createdAt)) +
178
+ mandatoryFlag);
179
+ });
180
+ if (data.totalReleases > limit) {
181
+ console.log(chalk_1.default.gray(`\n ... and ${data.totalReleases - limit} more. Use --limit to see more.`));
78
182
  }
79
- console.log("");
80
- });
81
- if (releases.length > limit) {
82
- console.log(chalk_1.default.gray(`... and ${releases.length - limit} more. Use --limit to see more.`));
83
183
  }
184
+ else {
185
+ console.log(chalk_1.default.yellow("\nNo releases found."));
186
+ }
187
+ console.log(""); // Empty line at end
84
188
  }
85
189
  catch (error) {
86
- console.error(chalk_1.default.red(`\nāœ— Failed to fetch releases: ${error.message}`));
190
+ console.error(chalk_1.default.red(`\nāœ— Failed to fetch status: ${error.message}`));
87
191
  process.exit(1);
88
192
  }
89
193
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upfluxhq/cli",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Upflux CLI for managing OTA updates",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",