@upfluxhq/cli 0.1.0-beta.1 → 0.1.0-beta.3

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
 
@@ -18,7 +18,7 @@ upflux login --key <staging-publish-key>
18
18
  upflux login --key <production-publish-key>
19
19
 
20
20
  # 2. Upload a release (will prompt if multiple deployments)
21
- upflux release --platform android --label v1.0.0 --mandatory
21
+ upflux release --label v1.0.0 --mandatory
22
22
 
23
23
  # 3. Check release status
24
24
  upflux status
@@ -40,9 +40,9 @@ 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
- Platform: android
46
46
  Deployment: production
47
47
 
48
48
  Stored credentials (2):
@@ -79,6 +79,10 @@ upflux logout --all
79
79
 
80
80
  Bundle and upload a new release. **Prompts to select deployment** if multiple are stored.
81
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
+
82
86
  ```bash
83
87
  upflux release [options]
84
88
  ```
@@ -86,8 +90,8 @@ upflux release [options]
86
90
  | Option | Required | Description |
87
91
  |--------|----------|-------------|
88
92
  | `-l, --label <label>` | āœ“ | Release label (e.g., v1.0.0) |
89
- | `-p, --platform <platform>` | | Target platform (`ios` or `android`, auto-detected) |
90
- | `-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) |
91
95
  | `-d, --deployment <deploymentId>` | | Deployment ID (prompts if multiple) |
92
96
  | `-e, --entry-file <path>` | | Entry file for bundler (default: `index.js`) |
93
97
  | `-o, --output-dir <path>` | | Output directory for bundle (default: `./dist`) |
@@ -98,21 +102,75 @@ upflux release [options]
98
102
  | `-v, --version-range <range>` | | Binary version range (e.g., >=1.0.0) |
99
103
  | `--dev` | | Create development bundle |
100
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) |
108
+
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
101
117
 
102
- **Example with deployment selection:**
118
+ # Android-only release with 50% rollout
119
+ upflux release --label v1.2.2 --platform android --rollout 50
120
+
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
103
129
  ```
104
- $ upflux release --platform android --label v1.2.0
105
130
 
106
- ? Select deployment to release to:
107
- āÆ production (app-970384d3)
108
- staging (app-970384d3)
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
+ ──────────────────────────────────
109
167
  ```
110
168
 
111
169
  ---
112
170
 
113
171
  ### `status`
114
172
 
115
- Check release status for an app or deployment.
173
+ Check deployment status and recent releases.
116
174
 
117
175
  ```bash
118
176
  upflux status [options]
@@ -120,9 +178,28 @@ upflux status [options]
120
178
 
121
179
  | Option | Description |
122
180
  |--------|-------------|
123
- | `-a, --app <appId>` | Filter by application ID |
124
- | `-d, --deployment <deploymentId>` | Filter by deployment ID |
125
- | `-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
+ ```
126
203
 
127
204
  ---
128
205
 
@@ -139,6 +216,50 @@ upflux generate-manifest [options]
139
216
  | `-r, --release-id <id>` | āœ“ | Release ID (MongoDB ObjectId) |
140
217
  | `-o, --output <path>` | | Save manifest to file |
141
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
+
142
263
  ---
143
264
 
144
265
  ## Configuration
@@ -147,7 +268,7 @@ upflux generate-manifest [options]
147
268
 
148
269
  | Variable | Description | Default |
149
270
  |----------|-------------|---------|
150
- | `UPFLUX_API_URL` | API server URL | `http://localhost:3000/api` |
271
+ | `UPFLUX_API_URL` | API server URL | `https://api.upflux.in/api` |
151
272
 
152
273
  ### Auth File
153
274
 
@@ -157,16 +278,14 @@ The CLI stores credentials in `.upflux` in the current directory:
157
278
  {
158
279
  "credentials": [
159
280
  {
160
- "key": "pk_xxx...",
281
+ "key": "upflux_pub_xxx...",
161
282
  "appId": "app-970384d3",
162
- "platform": "android",
163
283
  "deploymentId": "6933e683aa32fe18a1311bbb",
164
284
  "deploymentName": "production"
165
285
  },
166
286
  {
167
- "key": "pk_yyy...",
287
+ "key": "upflux_pub_yyy...",
168
288
  "appId": "app-970384d3",
169
- "platform": "android",
170
289
  "deploymentId": "6933e683aa32fe18a1311ccc",
171
290
  "deploymentName": "staging"
172
291
  }
@@ -175,6 +294,18 @@ The CLI stores credentials in `.upflux` in the current directory:
175
294
  }
176
295
  ```
177
296
 
297
+ > āš ļø **Important:** Never commit the `.upflux` file to version control. It contains your publish keys. Add it to your `.gitignore`.
298
+
299
+ ---
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
+
178
309
  ---
179
310
 
180
311
  ## Development
@@ -190,6 +321,12 @@ pnpm run build
190
321
  node dist/index.js --help
191
322
  ```
192
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
+
193
330
  ## License
194
331
 
195
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 = "https://api.upflux.in/api"; //"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 @@ async function verifyKeyWithServer(key) {
40
40
  return {
41
41
  valid: true,
42
42
  appId: response.data.appId,
43
- platform: response.data.platform,
44
43
  deploymentId: response.data.deploymentId,
45
44
  deploymentName: response.data.deploymentName,
46
45
  };
@@ -90,7 +89,6 @@ const login = new commander_1.Command("login")
90
89
  const credential = {
91
90
  key,
92
91
  appId: verification.appId,
93
- platform: verification.platform,
94
92
  deploymentId: verification.deploymentId,
95
93
  deploymentName: verification.deploymentName,
96
94
  };
@@ -101,9 +99,6 @@ const login = new commander_1.Command("login")
101
99
  ? `${verification.deploymentName}`
102
100
  : verification.deploymentId;
103
101
  console.log(chalk_1.default.gray(` App ID: ${verification.appId}`));
104
- if (verification.platform) {
105
- console.log(chalk_1.default.gray(` Platform: ${verification.platform}`));
106
- }
107
102
  console.log(chalk_1.default.gray(` Deployment: ${deploymentDisplay}`));
108
103
  // Show all stored credentials
109
104
  const allCreds = configStore_1.configStore.getCredentials();
@@ -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}`,
@@ -261,7 +259,7 @@ const release = new commander_1.Command("release")
261
259
  .option("-a, --app <appId>", "Application ID (uses stored value if not provided)")
262
260
  .option("-d, --deployment <deploymentId>", "Deployment ID (uses stored value if not provided)")
263
261
  .requiredOption("-l, --label <label>", "Release label (e.g., v1.0.0)")
264
- .option("-p, --platform <platform>", "Target platform (ios or android)")
262
+ .option("--platform <platform>", "Target platform for the release: all, ios, or android (defaults to all)", "all")
265
263
  .option("-e, --entry-file <path>", "Entry file for bundler", DEFAULT_ENTRY_FILE)
266
264
  .option("-o, --output-dir <path>", "Output directory for bundle", DEFAULT_OUTPUT_DIR)
267
265
  .option("-b, --bundle <path>", "Skip bundling, use existing bundle file")
@@ -275,7 +273,7 @@ const release = new commander_1.Command("release")
275
273
  .option("--capacitor", "Force Capacitor mode (auto-detected if capacitor.config exists)")
276
274
  .option("--web-dir <path>", "Web directory to bundle for Capacitor (default: from config or www)")
277
275
  .action(async (opts) => {
278
- var _a;
276
+ var _a, _b;
279
277
  try {
280
278
  // ========================================
281
279
  // VALIDATION PHASE
@@ -312,30 +310,23 @@ const release = new commander_1.Command("release")
312
310
  else {
313
311
  // Multiple credentials - prompt for selection
314
312
  selectedCredential = await selectDeployment(credentials);
313
+ // Update activeIndex so subsequent commands use the same deployment
314
+ const selectedIndex = credentials.findIndex(c => c.deploymentId === selectedCredential.deploymentId);
315
+ if (selectedIndex >= 0) {
316
+ configStore_1.configStore.setActiveIndex(selectedIndex);
317
+ }
315
318
  }
316
319
  const appId = selectedCredential.appId;
317
320
  const deploymentId = selectedCredential.deploymentId;
318
321
  const authKey = selectedCredential.key;
319
322
  const deploymentName = selectedCredential.deploymentName;
320
- // Validation 3: Platform - use stored value if not provided
321
- let platform = opts.platform;
322
- if (!platform) {
323
- if (selectedCredential.platform) {
324
- platform = selectedCredential.platform;
325
- console.log(chalk_1.default.gray(`Using stored platform: ${platform}`));
326
- }
327
- else {
328
- console.error(chalk_1.default.red("āœ— Platform is required"));
329
- console.error(chalk_1.default.gray("Provide --platform <ios|android> or re-login to store it."));
330
- process.exit(1);
331
- }
332
- }
333
- if (!VALID_PLATFORMS.includes(platform.toLowerCase())) {
323
+ // Validate platform
324
+ const platform = ((_a = opts.platform) === null || _a === void 0 ? void 0 : _a.toLowerCase()) || 'all';
325
+ if (!['all', 'ios', 'android'].includes(platform)) {
334
326
  console.error(chalk_1.default.red(`āœ— Invalid platform: ${platform}`));
335
- console.error(chalk_1.default.gray(`Valid platforms: ${VALID_PLATFORMS.join(", ")}`));
327
+ console.error(chalk_1.default.gray("Valid values: all, ios, android"));
336
328
  process.exit(1);
337
329
  }
338
- platform = platform.toLowerCase();
339
330
  // Validate app ID if provided matches selected credential
340
331
  if (opts.app && opts.app !== appId) {
341
332
  console.error(chalk_1.default.red(`āœ— App ID mismatch`));
@@ -376,6 +367,16 @@ const release = new commander_1.Command("release")
376
367
  // ========================================
377
368
  let bundlePath;
378
369
  let assetFiles = [];
370
+ // Dual-bundle variables
371
+ let iosBundlePath;
372
+ let androidBundlePath;
373
+ let iosAssetFiles = [];
374
+ let androidAssetFiles = [];
375
+ // Asset directories for calculating relative paths (preserves drawable-* structure)
376
+ // These point to the platform output directory (e.g., dist/android/) so paths include assets/
377
+ let assetsBaseDir;
378
+ let iosAssetsBaseDir;
379
+ let androidAssetsBaseDir;
379
380
  // Detect project type
380
381
  const isCapacitor = opts.capacitor || isCapacitorProject();
381
382
  if (isCapacitor) {
@@ -423,7 +424,7 @@ const release = new commander_1.Command("release")
423
424
  try {
424
425
  fs.accessSync(bundlePath, fs.constants.R_OK);
425
426
  }
426
- catch (_b) {
427
+ catch (_c) {
427
428
  console.error(chalk_1.default.red(`āœ— Cannot read bundle file: ${bundlePath}`));
428
429
  process.exit(1);
429
430
  }
@@ -449,37 +450,104 @@ const release = new commander_1.Command("release")
449
450
  }
450
451
  }
451
452
  const outputDir = path.resolve(opts.outputDir);
452
- const expectedBundle = path.join(outputDir, `index.${platform}.bundle`);
453
- const assetsDir = path.join(outputDir, "assets");
454
- if (opts.skipBundle) {
455
- if (!fs.existsSync(expectedBundle)) {
456
- console.error(chalk_1.default.red(`āœ— Bundle not found: ${expectedBundle}`));
457
- console.error(chalk_1.default.gray("Run without --skip-bundle to create a new bundle."));
458
- process.exit(1);
453
+ if (platform === 'all') {
454
+ // ========================================
455
+ // DUAL BUNDLING (iOS + Android)
456
+ // ========================================
457
+ if (!opts.skipBundle) {
458
+ // Bundle for iOS
459
+ console.log(chalk_1.default.blue("šŸ“¦ Bundling for iOS..."));
460
+ const iosOutputDir = path.join(outputDir, 'ios');
461
+ fs.mkdirSync(iosOutputDir, { recursive: true });
462
+ const iosResult = runBundler({
463
+ platform: 'ios',
464
+ entryFile: opts.entryFile,
465
+ outputDir: iosOutputDir,
466
+ dev: opts.dev,
467
+ });
468
+ iosBundlePath = iosResult.bundlePath;
469
+ console.log(chalk_1.default.green(` āœ“ iOS bundle created`));
470
+ // Bundle for Android
471
+ console.log(chalk_1.default.blue("šŸ“¦ Bundling for Android..."));
472
+ const androidOutputDir = path.join(outputDir, 'android');
473
+ fs.mkdirSync(androidOutputDir, { recursive: true });
474
+ const androidResult = runBundler({
475
+ platform: 'android',
476
+ entryFile: opts.entryFile,
477
+ outputDir: androidOutputDir,
478
+ dev: opts.dev,
479
+ });
480
+ androidBundlePath = androidResult.bundlePath;
481
+ console.log(chalk_1.default.green(` āœ“ Android bundle created`));
459
482
  }
460
- bundlePath = expectedBundle;
461
- console.log(chalk_1.default.blue("ā­ Skipping bundle, using existing files..."));
462
- }
463
- else {
464
- const result = runBundler({
465
- platform: platform,
466
- entryFile: opts.entryFile,
467
- outputDir,
468
- dev: opts.dev,
469
- });
470
- bundlePath = result.bundlePath;
471
- }
472
- if (opts.assets) {
473
- assetFiles = opts.assets.map((p) => path.resolve(p));
483
+ else {
484
+ // Skip bundle - use existing
485
+ const iosOutputDir = path.join(outputDir, 'ios');
486
+ const androidOutputDir = path.join(outputDir, 'android');
487
+ iosBundlePath = path.join(iosOutputDir, 'index.ios.bundle');
488
+ androidBundlePath = path.join(androidOutputDir, 'index.android.bundle');
489
+ if (!fs.existsSync(iosBundlePath) || !fs.existsSync(androidBundlePath)) {
490
+ console.error(chalk_1.default.red(`āœ— Bundle not found for both platforms`));
491
+ console.error(chalk_1.default.gray(`Expected: ${iosBundlePath} and ${androidBundlePath}`));
492
+ process.exit(1);
493
+ }
494
+ }
495
+ // Collect assets for iOS - base dir is the ios output dir so paths include assets/
496
+ const iosOutputDir = path.dirname(iosBundlePath);
497
+ iosAssetsBaseDir = iosOutputDir;
498
+ const iosAssetsPath = path.join(iosOutputDir, 'assets');
499
+ iosAssetFiles = opts.assets
500
+ ? opts.assets.map((p) => path.resolve(p))
501
+ : collectAssets(iosAssetsPath);
502
+ // Collect assets for Android - base dir is the android output dir so paths include assets/
503
+ const androidOutputDir = path.dirname(androidBundlePath);
504
+ androidAssetsBaseDir = androidOutputDir;
505
+ const androidAssetsPath = path.join(androidOutputDir, 'assets');
506
+ androidAssetFiles = opts.assets
507
+ ? opts.assets.map((p) => path.resolve(p))
508
+ : collectAssets(androidAssetsPath);
509
+ // Set bundlePath and assetFiles for display (use iOS as primary for logging)
510
+ bundlePath = iosBundlePath;
511
+ assetFiles = [...iosAssetFiles, ...androidAssetFiles];
474
512
  }
475
513
  else {
476
- assetFiles = collectAssets(assetsDir);
477
- }
478
- // Validate asset files
479
- const invalidAssets = assetFiles.filter(p => !fs.existsSync(p));
480
- if (invalidAssets.length > 0) {
481
- console.warn(chalk_1.default.yellow(`⚠ ${invalidAssets.length} asset(s) not found, will be skipped`));
482
- assetFiles = assetFiles.filter(p => fs.existsSync(p));
514
+ // ========================================
515
+ // SINGLE PLATFORM BUNDLING
516
+ // ========================================
517
+ const expectedBundle = path.join(outputDir, `index.${platform}.bundle`);
518
+ // Base dir is outputDir so relative paths include assets/ folder
519
+ assetsBaseDir = outputDir;
520
+ const assetsPath = path.join(outputDir, "assets");
521
+ if (opts.skipBundle) {
522
+ if (!fs.existsSync(expectedBundle)) {
523
+ console.error(chalk_1.default.red(`āœ— Bundle not found: ${expectedBundle}`));
524
+ console.error(chalk_1.default.gray("Run without --skip-bundle to create a new bundle."));
525
+ process.exit(1);
526
+ }
527
+ bundlePath = expectedBundle;
528
+ console.log(chalk_1.default.blue("ā­ Skipping bundle, using existing files..."));
529
+ }
530
+ else {
531
+ const result = runBundler({
532
+ platform: platform,
533
+ entryFile: opts.entryFile,
534
+ outputDir,
535
+ dev: opts.dev,
536
+ });
537
+ bundlePath = result.bundlePath;
538
+ }
539
+ if (opts.assets) {
540
+ assetFiles = opts.assets.map((p) => path.resolve(p));
541
+ }
542
+ else {
543
+ assetFiles = collectAssets(assetsPath);
544
+ }
545
+ // Validate asset files
546
+ const invalidAssets = assetFiles.filter(p => !fs.existsSync(p));
547
+ if (invalidAssets.length > 0) {
548
+ console.warn(chalk_1.default.yellow(`⚠ ${invalidAssets.length} asset(s) not found, will be skipped`));
549
+ assetFiles = assetFiles.filter(p => fs.existsSync(p));
550
+ }
483
551
  }
484
552
  }
485
553
  // ========================================
@@ -501,51 +569,120 @@ const release = new commander_1.Command("release")
501
569
  if (opts.versionRange) {
502
570
  console.log(` Version Range: ${opts.versionRange}`);
503
571
  }
504
- // Create form data
505
- const form = new form_data_1.default();
506
- form.append("bundle", fs.createReadStream(bundlePath));
507
- form.append("releaseLabel", opts.label);
508
- form.append("appId", appId);
509
- form.append("deploymentId", deploymentId);
510
- form.append("deploymentName", deploymentName);
511
- form.append("mandatory", String(opts.mandatory));
512
- form.append("rolloutPercentage", String(rolloutValidation.value));
513
- if (opts.schedule) {
514
- form.append("isDraft", "true");
515
- }
516
- if (opts.versionRange) {
517
- form.append("binaryVersionRange", opts.versionRange);
572
+ // ========================================
573
+ // PRESIGNED URL UPLOAD FLOW
574
+ // ========================================
575
+ // This flow bypasses Vercel's 4.5MB body size limit by:
576
+ // 1. Requesting presigned URLs from the API
577
+ // 2. Uploading files directly to R2
578
+ // 3. Confirming upload completion
579
+ const { buildFileManifest, uploadFilesWithPresignedUrls } = await Promise.resolve().then(() => __importStar(require("../client/directUpload")));
580
+ // Build file manifest for presigned URL request
581
+ 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,
582
+ // Asset base directories for relative path calculation
583
+ platform !== 'all' ? assetsBaseDir : undefined, platform === 'all' ? iosAssetsBaseDir : undefined, platform === 'all' ? androidAssetsBaseDir : undefined);
584
+ console.log(chalk_1.default.blue("\nšŸ“‹ Step 1/3: Requesting upload URLs..."));
585
+ // Step 1: Request presigned URLs
586
+ const initResponse = await apiClient_1.apiClient.post("/cli/releases/upload/init", {
587
+ appId,
588
+ deploymentId,
589
+ releaseLabel: opts.label,
590
+ platform,
591
+ files: fileManifest.map(f => ({
592
+ type: f.type,
593
+ platform: f.platform,
594
+ filename: f.filename,
595
+ relativePath: f.relativePath, // Preserves folder structure like drawable-mdpi/logo.png
596
+ contentType: f.contentType,
597
+ })),
598
+ }, {
599
+ headers: {
600
+ "x-publish-key": authKey,
601
+ "Content-Type": "application/json",
602
+ },
603
+ });
604
+ if (!initResponse.data.success) {
605
+ throw new Error("Failed to initialize upload");
518
606
  }
519
- // Add asset files
520
- for (const assetPath of assetFiles) {
521
- form.append("assets", fs.createReadStream(assetPath));
607
+ console.log(chalk_1.default.green(` āœ“ Got ${initResponse.data.urls.length} presigned URLs`));
608
+ console.log(chalk_1.default.blue("\nšŸ“¤ Step 2/3: Uploading files directly to storage..."));
609
+ // Step 2: Upload files directly to R2
610
+ let uploadedCount = 0;
611
+ await uploadFilesWithPresignedUrls(fileManifest, initResponse.data.urls, {
612
+ concurrency: 3,
613
+ onFileComplete: (filename, index, total) => {
614
+ uploadedCount++;
615
+ console.log(chalk_1.default.gray(` [${uploadedCount}/${total}] ${filename}`));
616
+ },
617
+ });
618
+ console.log(chalk_1.default.green(` āœ“ Uploaded ${fileManifest.length} files`));
619
+ console.log(chalk_1.default.blue("\nāœ… Step 3/3: Confirming release..."));
620
+ // Calculate bundle sizes from manifest
621
+ const bundleSizes = {};
622
+ for (const file of fileManifest) {
623
+ if (file.type === 'bundle' && file.size) {
624
+ if (file.platform === 'ios') {
625
+ bundleSizes.ios = file.size;
626
+ }
627
+ else if (file.platform === 'android') {
628
+ bundleSizes.android = file.size;
629
+ }
630
+ else {
631
+ bundleSizes.single = file.size;
632
+ }
633
+ }
522
634
  }
523
- // Upload to API (using selected credential's key)
524
- const response = await apiClient_1.apiClient.post("/releases", form, {
525
- headers: Object.assign(Object.assign({}, form.getHeaders()), { "x-publish-key": authKey }),
635
+ // Step 3: Complete upload and create release record
636
+ const completeResponse = await apiClient_1.apiClient.post("/cli/releases/upload/complete", {
637
+ uploadId: initResponse.data.uploadId,
638
+ releaseLabel: opts.label,
639
+ mandatory: opts.mandatory,
640
+ rolloutPercentage: rolloutValidation.value,
641
+ binaryVersionRange: opts.versionRange,
642
+ platform,
643
+ isDraft: opts.schedule,
644
+ // Include bundle sizes
645
+ iosBundleSize: bundleSizes.ios,
646
+ androidBundleSize: bundleSizes.android || bundleSizes.single,
647
+ }, {
648
+ headers: {
649
+ "x-publish-key": authKey,
650
+ "Content-Type": "application/json",
651
+ },
526
652
  });
653
+ if (!completeResponse.data.success) {
654
+ throw new Error("Failed to complete upload");
655
+ }
656
+ const releaseData = completeResponse.data.data;
527
657
  console.log(chalk_1.default.green("\nāœ“ Release uploaded successfully!"));
528
658
  console.log(chalk_1.default.gray("──────────────────────────────────"));
529
- console.log(` ID: ${response.data.id}`);
530
- console.log(` Label: ${response.data.releaseLabel}`);
531
- console.log(` Bundle URL: ${response.data.bundleUrl}`);
532
- console.log(` Rollout: ${response.data.rolloutPercentage}%`);
533
- console.log(` Mandatory: ${response.data.mandatory ? "Yes" : "No"}`);
534
- if (response.data.status === 'draft') {
659
+ console.log(` ID: ${releaseData.id}`);
660
+ console.log(` Label: ${releaseData.releaseLabel}`);
661
+ // Display bundle URLs based on platform
662
+ if (platform === 'all') {
663
+ console.log(` iOS Bundle: ${releaseData.iosBundleUrl || 'uploaded'}`);
664
+ console.log(` Android Bundle: ${releaseData.androidBundleUrl || 'uploaded'}`);
665
+ }
666
+ else {
667
+ console.log(` Bundle URL: ${releaseData.bundleUrl || releaseData.iosBundleUrl || releaseData.androidBundleUrl}`);
668
+ }
669
+ console.log(` Rollout: ${releaseData.rolloutPercentage}%`);
670
+ console.log(` Mandatory: ${releaseData.mandatory ? "Yes" : "No"}`);
671
+ if (releaseData.status === 'draft') {
535
672
  console.log(chalk_1.default.yellow(` Status: DRAFT`));
536
673
  console.log(chalk_1.default.gray(` šŸ“… Set schedule time in dashboard`));
537
674
  }
538
675
  else {
539
- console.log(` Status: ${response.data.status || 'active'}`);
676
+ console.log(` Status: ${releaseData.status || 'active'}`);
540
677
  }
541
- if (response.data.binaryVersionRange) {
542
- console.log(` Version Range: ${response.data.binaryVersionRange}`);
678
+ if (releaseData.binaryVersionRange) {
679
+ console.log(` Version Range: ${releaseData.binaryVersionRange}`);
543
680
  }
544
681
  console.log(chalk_1.default.gray("──────────────────────────────────"));
545
682
  }
546
683
  catch (error) {
547
684
  console.error(chalk_1.default.red(`\nāœ— Failed to upload release: ${error.message}`));
548
- if ((_a = error.response) === null || _a === void 0 ? void 0 : _a.data) {
685
+ if ((_b = error.response) === null || _b === void 0 ? void 0 : _b.data) {
549
686
  console.error(chalk_1.default.gray(JSON.stringify(error.response.data, null, 2)));
550
687
  }
551
688
  process.exit(1);
@@ -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
  });
@@ -4,7 +4,6 @@
4
4
  export interface Credential {
5
5
  key: string;
6
6
  appId: string;
7
- platform?: 'ios' | 'android';
8
7
  deploymentId: string;
9
8
  deploymentName?: string;
10
9
  }
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ const program = new commander_1.Command();
14
14
  program
15
15
  .name("upflux")
16
16
  .description("Upflux CLI for OTA updates")
17
- .version("0.0.1");
17
+ .version("0.1.0-beta.1");
18
18
  program.addCommand(login_1.default);
19
19
  program.addCommand(logout_1.default);
20
20
  program.addCommand(release_1.default);
@@ -20,8 +20,15 @@ export interface ReleaseManifest {
20
20
  export interface ReleaseResponse {
21
21
  id: string;
22
22
  releaseLabel: string;
23
- bundleUrl: string;
24
- assets: string[];
23
+ bundleUrl?: string;
24
+ assets?: string[];
25
+ iosBundleUrl?: string;
26
+ androidBundleUrl?: string;
27
+ iosBundleSize?: number;
28
+ androidBundleSize?: number;
29
+ iosAssets?: string[];
30
+ androidAssets?: string[];
31
+ platform?: 'all' | 'ios' | 'android';
25
32
  rolloutPercentage: number;
26
33
  mandatory: boolean;
27
34
  binaryVersionRange?: string;
@@ -73,7 +80,7 @@ export interface AppResponse {
73
80
  id: string;
74
81
  appId: string;
75
82
  name: string;
76
- platform: string;
83
+ sdkType: string;
77
84
  createdAt: string;
78
85
  updatedAt?: string;
79
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upfluxhq/cli",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.3",
4
4
  "description": "Upflux CLI for managing OTA updates",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",