ubriot 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1650 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const commander_1 = require("commander");
5
+ const readline_1 = require("./readline");
6
+ const config_1 = require("./config");
7
+ const api_1 = require("./api");
8
+ const child_process_1 = require("child_process");
9
+ const fs_1 = require("fs");
10
+ const path_1 = require("path");
11
+ const crypto_1 = require("crypto");
12
+ commander_1.program.name("ubriot").description("Ubriot — self-hosted Expo release infrastructure").version("0.1.0");
13
+ const MIME = {
14
+ ".avif": "image/avif",
15
+ ".bundle": "application/javascript",
16
+ ".gif": "image/gif",
17
+ ".heic": "image/heic",
18
+ ".jpeg": "image/jpeg",
19
+ ".jpg": "image/jpeg",
20
+ ".js": "application/javascript",
21
+ ".json": "application/json",
22
+ ".m4a": "audio/mp4",
23
+ ".mp3": "audio/mpeg",
24
+ ".mp4": "video/mp4",
25
+ ".otf": "font/otf",
26
+ ".png": "image/png",
27
+ ".ttf": "font/ttf",
28
+ ".webp": "image/webp",
29
+ ".wav": "audio/wav",
30
+ ".woff": "font/woff",
31
+ ".woff2": "font/woff2",
32
+ };
33
+ function base64Url(buffer) {
34
+ return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
35
+ }
36
+ function sha256Base64Url(buffer) {
37
+ return base64Url((0, crypto_1.createHash)("sha256").update(buffer).digest());
38
+ }
39
+ function sha256Hex(buffer) {
40
+ return (0, crypto_1.createHash)("sha256").update(buffer).digest("hex");
41
+ }
42
+ function md5Hex(buffer) {
43
+ return (0, crypto_1.createHash)("md5").update(buffer).digest("hex");
44
+ }
45
+ function readJsonFile(path) {
46
+ return JSON.parse((0, fs_1.readFileSync)(path, "utf8"));
47
+ }
48
+ function readExpoConfig(path = "app.json") {
49
+ const configPath = (0, path_1.resolve)(path);
50
+ if (!(0, fs_1.existsSync)(configPath))
51
+ return {};
52
+ const configJson = readJsonFile(configPath);
53
+ return configJson.expo ?? configJson;
54
+ }
55
+ function readExpoConfigFromPath(configPath) {
56
+ const configJson = readJsonFile(configPath);
57
+ return configJson.expo ?? configJson;
58
+ }
59
+ function hasExpoIdentity(config) {
60
+ return Boolean(config?.slug ||
61
+ config?.name ||
62
+ config?.ios?.bundleIdentifier ||
63
+ config?.android?.package ||
64
+ config?.runtimeVersion);
65
+ }
66
+ function scoreExpoConfigCandidate(config, filePath, rootDir, app) {
67
+ let score = hasExpoIdentity(config) ? 0 : -200;
68
+ const appNeedle = String(app ?? "").toLowerCase();
69
+ const slug = String(config?.slug ?? "").toLowerCase();
70
+ const pushSlug = String(config?.extra?.ubriot?.pushAppSlug ?? "").toLowerCase();
71
+ const loweredPath = filePath.toLowerCase();
72
+ if (appNeedle && (slug === appNeedle || pushSlug === appNeedle))
73
+ score += 500;
74
+ if (appNeedle && loweredPath.includes(`/${appNeedle}/`))
75
+ score += 80;
76
+ if (config?.ios?.bundleIdentifier)
77
+ score += 25;
78
+ if (config?.android?.package)
79
+ score += 25;
80
+ if ((0, fs_1.existsSync)((0, path_1.join)((0, path_1.dirname)(filePath), "google-services.json")))
81
+ score += 10;
82
+ const depthPenalty = (0, path_1.relative)(rootDir, filePath).split(/[\\/]/).length;
83
+ return score - depthPenalty;
84
+ }
85
+ function discoverExpoConfig(input) {
86
+ const rootDir = (0, path_1.resolve)(input.projectDir ?? process.cwd());
87
+ if (input.expoConfig) {
88
+ const configPath = (0, path_1.resolve)(rootDir, input.expoConfig);
89
+ if (!(0, fs_1.existsSync)(configPath))
90
+ throw new Error(`Expo config not found: ${configPath}`);
91
+ return { config: readExpoConfigFromPath(configPath), configPath, projectDir: (0, path_1.dirname)(configPath) };
92
+ }
93
+ const candidates = [];
94
+ const seen = new Set();
95
+ const skipDirs = new Set([".git", ".next", ".expo", "android", "build", "dist", "ios", "node_modules"]);
96
+ const collect = (configPath) => {
97
+ const resolved = (0, path_1.resolve)(configPath);
98
+ if (seen.has(resolved) || !(0, fs_1.existsSync)(resolved))
99
+ return;
100
+ seen.add(resolved);
101
+ try {
102
+ const config = readExpoConfigFromPath(resolved);
103
+ candidates.push({ config, configPath: resolved, score: scoreExpoConfigCandidate(config, resolved, rootDir, input.app) });
104
+ }
105
+ catch {
106
+ // Ignore app.json files that are not valid JSON.
107
+ }
108
+ };
109
+ collect((0, path_1.join)(rootDir, "app.json"));
110
+ const walk = (dir, depth) => {
111
+ if (depth <= 0)
112
+ return;
113
+ let entries;
114
+ try {
115
+ entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true });
116
+ }
117
+ catch {
118
+ return;
119
+ }
120
+ for (const entry of entries) {
121
+ const fullPath = (0, path_1.join)(dir, entry.name);
122
+ if (entry.isFile() && entry.name === "app.json")
123
+ collect(fullPath);
124
+ if (entry.isDirectory() && !skipDirs.has(entry.name))
125
+ walk(fullPath, depth - 1);
126
+ }
127
+ };
128
+ walk(rootDir, 5);
129
+ candidates.sort((a, b) => b.score - a.score);
130
+ const best = candidates[0];
131
+ if (!best || best.score < -100)
132
+ return { config: {}, configPath: undefined, projectDir: rootDir };
133
+ return { config: best.config, configPath: best.configPath, projectDir: (0, path_1.dirname)(best.configPath) };
134
+ }
135
+ function normalizePushPlatforms(platform) {
136
+ const value = String(platform ?? "all").toLowerCase();
137
+ if (value === "all")
138
+ return ["ios", "android"];
139
+ if (value === "ios" || value === "android")
140
+ return [value];
141
+ throw new Error("--platform must be ios, android, or all");
142
+ }
143
+ function findLocalFiles(rootDir, matches, maxDepth = 4) {
144
+ const results = [];
145
+ const skipDirs = new Set([".git", ".next", ".expo", "android", "build", "dist", "ios", "node_modules"]);
146
+ const walk = (dir, depth) => {
147
+ if (depth <= 0)
148
+ return;
149
+ let entries;
150
+ try {
151
+ entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true });
152
+ }
153
+ catch {
154
+ return;
155
+ }
156
+ for (const entry of entries) {
157
+ const fullPath = (0, path_1.join)(dir, entry.name);
158
+ if (entry.isFile() && matches(fullPath))
159
+ results.push(fullPath);
160
+ if (entry.isDirectory() && !skipDirs.has(entry.name))
161
+ walk(fullPath, depth - 1);
162
+ }
163
+ };
164
+ walk(rootDir, maxDepth);
165
+ return results;
166
+ }
167
+ function resolveExistingPath(filePath, baseDir = process.cwd()) {
168
+ if (!filePath)
169
+ return undefined;
170
+ const resolved = (0, path_1.resolve)(baseDir, filePath);
171
+ if (!(0, fs_1.existsSync)(resolved))
172
+ throw new Error(`File not found: ${resolved}`);
173
+ return resolved;
174
+ }
175
+ function looksLikeFirebaseServiceAccount(filePath) {
176
+ try {
177
+ const json = readJsonFile(filePath);
178
+ return (json?.type === "service_account" &&
179
+ typeof json.project_id === "string" &&
180
+ typeof json.private_key === "string" &&
181
+ typeof json.client_email === "string");
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ }
187
+ function findFirebaseServiceAccount(projectDir) {
188
+ const likelyNames = new Set([
189
+ "firebase-service-account.json",
190
+ "firebase-adminsdk.json",
191
+ "service-account.json",
192
+ "serviceAccount.json",
193
+ ]);
194
+ const candidates = findLocalFiles(projectDir, (filePath) => likelyNames.has((0, path_1.basename)(filePath)) || (0, path_1.basename)(filePath).endsWith("-firebase-adminsdk.json"), 4);
195
+ return candidates.find(looksLikeFirebaseServiceAccount);
196
+ }
197
+ function findApnsKeyP8(projectDir) {
198
+ const candidates = findLocalFiles(projectDir, (filePath) => (0, path_1.extname)(filePath).toLowerCase() === ".p8", 4);
199
+ const authKey = candidates.find((filePath) => /^AuthKey_[A-Za-z0-9]+\.p8$/.test((0, path_1.basename)(filePath)));
200
+ return authKey ?? (candidates.length === 1 ? candidates[0] : undefined);
201
+ }
202
+ function inferApnsKeyId(filePath) {
203
+ if (!filePath)
204
+ return undefined;
205
+ const match = (0, path_1.basename)(filePath).match(/^AuthKey_([A-Za-z0-9]+)\.p8$/);
206
+ return match?.[1];
207
+ }
208
+ async function promptRequired(label, value, option) {
209
+ if (value)
210
+ return value;
211
+ if (!process.stdin.isTTY)
212
+ throw new Error(`Missing ${label}. Pass ${option}.`);
213
+ const answer = (await (0, readline_1.readlineSync)(`${label}: `)).trim();
214
+ if (!answer)
215
+ throw new Error(`Missing ${label}. Pass ${option}.`);
216
+ return answer;
217
+ }
218
+ async function promptOptionalPath(label, value, option, baseDir) {
219
+ if (value)
220
+ return resolveExistingPath(value, baseDir);
221
+ if (!process.stdin.isTTY)
222
+ return undefined;
223
+ const answer = (await (0, readline_1.readlineSync)(`${label} (leave blank to skip): `)).trim();
224
+ if (!answer)
225
+ return undefined;
226
+ return resolveExistingPath(answer, baseDir);
227
+ }
228
+ function resolveAppSlug(app, expoConfig) {
229
+ const slug = app ?? expoConfig.slug;
230
+ if (!slug)
231
+ throw new Error("No app specified. Pass --app <appSlug> or run from an Expo app with expo.slug in app.json.");
232
+ return slug;
233
+ }
234
+ function resolveRuntimeVersion(runtimeVersion, expoConfig) {
235
+ const runtime = runtimeVersion ?? expoConfig.runtimeVersion;
236
+ if (!runtime)
237
+ throw new Error("No runtime version specified. Pass --runtime-version <version> or set runtimeVersion in app.json.");
238
+ if (typeof runtime === "string")
239
+ return runtime;
240
+ if (runtime.policy)
241
+ throw new Error("Runtime version policies must be resolved before publishing. Pass --runtime-version <version>.");
242
+ throw new Error("Unsupported runtimeVersion in app.json. Pass --runtime-version <version>.");
243
+ }
244
+ function optionalGitShortSha() {
245
+ const result = (0, child_process_1.spawnSync)("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" });
246
+ if (result.status !== 0)
247
+ return undefined;
248
+ return result.stdout.trim() || undefined;
249
+ }
250
+ function runLocalCommand(command, args) {
251
+ const result = (0, child_process_1.spawnSync)(command, args, { stdio: "inherit", env: process.env });
252
+ if (result.error)
253
+ throw result.error;
254
+ if (result.status !== 0)
255
+ throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}`);
256
+ }
257
+ function contentTypeFor(filePath, fallback = "application/octet-stream") {
258
+ return MIME[(0, path_1.extname)(filePath).toLowerCase()] ?? fallback;
259
+ }
260
+ const BUILD_TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled"]);
261
+ const ASC_TERMINAL_STATUSES = new Set(["succeeded", "failed"]);
262
+ const PLAY_TERMINAL_STATUSES = new Set(["succeeded", "failed"]);
263
+ function sleep(ms) {
264
+ return new Promise((resolve) => setTimeout(resolve, ms));
265
+ }
266
+ function parsePositiveNumber(value, fallback, label) {
267
+ if (value === undefined)
268
+ return fallback;
269
+ const parsed = Number(value);
270
+ if (!Number.isFinite(parsed) || parsed <= 0)
271
+ throw new Error(`${label} must be a positive number`);
272
+ return parsed;
273
+ }
274
+ function buildStatusLine(build) {
275
+ const asc = build.ascSubmissionStatus ? `, TestFlight ${build.ascSubmissionStatus}` : "";
276
+ const play = build.playSubmissionStatus ? `, Google Play ${build.playSubmissionStatus}` : "";
277
+ const processing = build.ascProcessingState ? ` (${build.ascProcessingState})` : "";
278
+ return `Build #${build.id}: ${build.status}${asc}${play}${processing}`;
279
+ }
280
+ async function waitForBuild(client, buildId, intervalMs, timeoutMs) {
281
+ const deadline = Date.now() + timeoutMs;
282
+ let lastLine = "";
283
+ while (Date.now() <= deadline) {
284
+ const { data } = await client.get(`/ubriot/builds/${buildId}`);
285
+ const line = buildStatusLine(data);
286
+ if (line !== lastLine) {
287
+ console.log(line);
288
+ lastLine = line;
289
+ }
290
+ if (BUILD_TERMINAL_STATUSES.has(data.status)) {
291
+ if (data.status === "succeeded")
292
+ return data;
293
+ throw new Error(`Build #${buildId} ${data.status}${data.errorMessage ? `: ${data.errorMessage}` : ""}`);
294
+ }
295
+ await sleep(intervalMs);
296
+ }
297
+ throw new Error(`Timed out waiting for build #${buildId}`);
298
+ }
299
+ async function resolveReleaseApp(client, appIdOrSlug) {
300
+ const value = String(appIdOrSlug ?? "").trim();
301
+ if (!value)
302
+ throw new Error("No app specified. Pass --app <appIdOrSlug>.");
303
+ if (/^\d+$/.test(value)) {
304
+ const { data } = await client.get(`/ubriot/apps/${value}`);
305
+ return data;
306
+ }
307
+ const { data } = await client.get("/ubriot/apps");
308
+ const matches = data.filter((app) => app.slug === value || app.name.toLowerCase() === value.toLowerCase());
309
+ if (matches.length === 1)
310
+ return matches[0];
311
+ if (matches.length > 1)
312
+ throw new Error(`Multiple apps match "${value}". Pass the numeric app id instead.`);
313
+ throw new Error(`App "${value}" not found. Run: ubriot apps list`);
314
+ }
315
+ async function queueTestFlightIfNeeded(client, build) {
316
+ if (build.platform !== "ios")
317
+ throw new Error("--submit-testflight is only valid for iOS builds");
318
+ if (build.ascSubmissionStatus === "succeeded") {
319
+ console.log(`Build #${build.id} was already submitted to TestFlight.`);
320
+ return build;
321
+ }
322
+ if (build.ascSubmissionStatus === "queued" || build.ascSubmissionStatus === "running") {
323
+ console.log(`Build #${build.id} already has TestFlight submission ${build.ascSubmissionStatus}.`);
324
+ return build;
325
+ }
326
+ const { data } = await client.post(`/ubriot/builds/${build.id}/submit-testflight`);
327
+ console.log(`Build #${build.id} queued for TestFlight submit.`);
328
+ return data;
329
+ }
330
+ async function waitForTestFlight(client, buildId, intervalMs, timeoutMs) {
331
+ const deadline = Date.now() + timeoutMs;
332
+ let lastLine = "";
333
+ while (Date.now() <= deadline) {
334
+ const { data } = await client.get(`/ubriot/builds/${buildId}`);
335
+ const status = data.ascSubmissionStatus ?? "not queued";
336
+ const processing = data.ascProcessingState ? ` (${data.ascProcessingState})` : "";
337
+ const line = `TestFlight #${buildId}: ${status}${processing}`;
338
+ if (line !== lastLine) {
339
+ console.log(line);
340
+ lastLine = line;
341
+ }
342
+ if (ASC_TERMINAL_STATUSES.has(status)) {
343
+ if (status === "succeeded")
344
+ return data;
345
+ throw new Error(`TestFlight submit for build #${buildId} failed${data.ascSubmissionError ? `: ${data.ascSubmissionError}` : ""}`);
346
+ }
347
+ await sleep(intervalMs);
348
+ }
349
+ throw new Error(`Timed out waiting for TestFlight submit for build #${buildId}`);
350
+ }
351
+ async function queueGooglePlayIfNeeded(client, build, track) {
352
+ if (build.platform !== "android")
353
+ throw new Error("--submit-google-play is only valid for Android builds");
354
+ if (build.playSubmissionStatus === "succeeded") {
355
+ console.log(`Build #${build.id} was already submitted to Google Play.`);
356
+ return build;
357
+ }
358
+ if (build.playSubmissionStatus === "queued" || build.playSubmissionStatus === "running") {
359
+ console.log(`Build #${build.id} already has Google Play submission ${build.playSubmissionStatus}.`);
360
+ return build;
361
+ }
362
+ const { data } = await client.post(`/ubriot/builds/${build.id}/submit-google-play`, track ? { track } : {});
363
+ console.log(`Build #${build.id} queued for Google Play submit.`);
364
+ return data;
365
+ }
366
+ async function waitForGooglePlay(client, buildId, intervalMs, timeoutMs) {
367
+ const deadline = Date.now() + timeoutMs;
368
+ let lastLine = "";
369
+ while (Date.now() <= deadline) {
370
+ const { data } = await client.get(`/ubriot/builds/${buildId}`);
371
+ const status = data.playSubmissionStatus ?? "not queued";
372
+ const track = data.playTrack ? ` (${data.playTrack})` : "";
373
+ const version = data.playVersionCode ? ` version ${data.playVersionCode}` : "";
374
+ const line = `Google Play #${buildId}: ${status}${track}${version}`;
375
+ if (line !== lastLine) {
376
+ console.log(line);
377
+ lastLine = line;
378
+ }
379
+ if (PLAY_TERMINAL_STATUSES.has(status)) {
380
+ if (status === "succeeded")
381
+ return data;
382
+ throw new Error(`Google Play submit for build #${buildId} failed${data.playSubmissionError ? `: ${data.playSubmissionError}` : ""}`);
383
+ }
384
+ await sleep(intervalMs);
385
+ }
386
+ throw new Error(`Timed out waiting for Google Play submit for build #${buildId}`);
387
+ }
388
+ function sanitizePart(value) {
389
+ return value.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80);
390
+ }
391
+ function updateAssetUrl(assetKey, publicApiBase) {
392
+ return `${publicApiBase.replace(/\/$/, "")}/ubriot/updates/assets/${encodeURIComponent(assetKey)}`;
393
+ }
394
+ async function uploadOtaFile(client, app, assetKey, filePath, contentType, publicApiBase) {
395
+ const buffer = (0, fs_1.readFileSync)(filePath);
396
+ const { data } = await client.put(`/ubriot/apps/${encodeURIComponent(app)}/updates/assets`, buffer, {
397
+ params: { assetKey, fileName: (0, path_1.basename)(filePath) },
398
+ headers: {
399
+ "Content-Type": contentType,
400
+ "Content-Length": String(buffer.length),
401
+ },
402
+ maxBodyLength: Infinity,
403
+ maxContentLength: Infinity,
404
+ timeout: 120000,
405
+ });
406
+ return {
407
+ assetKey,
408
+ storagePath: data.storagePath,
409
+ fileName: (0, path_1.basename)(filePath),
410
+ contentType,
411
+ sizeBytes: (0, fs_1.statSync)(filePath).size,
412
+ sha256: sha256Hex(buffer),
413
+ manifestAsset: {
414
+ hash: sha256Base64Url(buffer),
415
+ key: md5Hex(buffer),
416
+ fileExtension: "",
417
+ contentType,
418
+ url: updateAssetUrl(assetKey, publicApiBase),
419
+ },
420
+ };
421
+ }
422
+ async function buildOtaPlatformPayload(input) {
423
+ const platformMetadata = input.metadataJson.fileMetadata?.[input.platform];
424
+ if (!platformMetadata?.bundle)
425
+ return null;
426
+ const uploadedAssets = [];
427
+ const uploadedForDb = [];
428
+ const uploadOne = async (relativePath, ext, isLaunchAsset) => {
429
+ const filePath = (0, path_1.resolve)(input.exportDir, relativePath);
430
+ if (!(0, fs_1.existsSync)(filePath))
431
+ throw new Error(`Expo export file missing: ${filePath}`);
432
+ const buffer = (0, fs_1.readFileSync)(filePath);
433
+ const fileHash = md5Hex(buffer);
434
+ const suffix = isLaunchAsset ? "bundle" : (ext ?? ((0, path_1.extname)(filePath).replace(/^\./, "") || "asset"));
435
+ const assetKey = [
436
+ sanitizePart(input.app),
437
+ sanitizePart(input.runtimeVersion),
438
+ input.platform,
439
+ input.publishId,
440
+ fileHash,
441
+ suffix,
442
+ ].join(".");
443
+ const contentType = isLaunchAsset ? "application/javascript" : contentTypeFor(filePath);
444
+ const uploaded = await uploadOtaFile(input.client, input.app, assetKey, filePath, contentType, input.publicApiBase);
445
+ uploaded.manifestAsset.fileExtension = `.${isLaunchAsset ? "bundle" : suffix}`;
446
+ uploadedForDb.push({
447
+ assetKey: uploaded.assetKey,
448
+ storagePath: uploaded.storagePath,
449
+ fileName: uploaded.fileName,
450
+ contentType: uploaded.contentType,
451
+ sizeBytes: uploaded.sizeBytes,
452
+ sha256: uploaded.sha256,
453
+ });
454
+ return uploaded.manifestAsset;
455
+ };
456
+ for (const asset of platformMetadata.assets ?? []) {
457
+ uploadedAssets.push(await uploadOne(asset.path, asset.ext, false));
458
+ }
459
+ const launchAsset = await uploadOne(platformMetadata.bundle, null, true);
460
+ const manifest = {
461
+ id: (0, crypto_1.randomUUID)(),
462
+ createdAt: new Date().toISOString(),
463
+ runtimeVersion: input.runtimeVersion,
464
+ assets: uploadedAssets,
465
+ launchAsset,
466
+ metadata: {
467
+ updateId: input.publishId,
468
+ message: input.message ?? null,
469
+ },
470
+ extra: {
471
+ expoClient: input.expoConfig,
472
+ },
473
+ };
474
+ return {
475
+ platform: input.platform,
476
+ manifestJson: JSON.stringify(manifest),
477
+ assets: uploadedForDb,
478
+ };
479
+ }
480
+ async function publishExportedUpdate(input) {
481
+ const exportDir = (0, path_1.resolve)(input.exportDir);
482
+ const metadataPath = (0, path_1.join)(exportDir, "metadata.json");
483
+ if (!(0, fs_1.existsSync)(metadataPath))
484
+ throw new Error(`No metadata.json found in ${exportDir}. Run: npx expo export --output-dir ${input.exportDir}`);
485
+ const metadataJson = readJsonFile(metadataPath);
486
+ const expoConfig = readExpoConfig(input.expoConfigPath);
487
+ const publishId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
488
+ const selectedPlatforms = input.platform === "all" ? ["ios", "android"] : [input.platform];
489
+ const platforms = [];
490
+ for (const platform of selectedPlatforms) {
491
+ if (platform !== "ios" && platform !== "android")
492
+ throw new Error("--platform must be ios, android, or all");
493
+ const payload = await buildOtaPlatformPayload({
494
+ client: input.client,
495
+ app: input.app,
496
+ exportDir,
497
+ publishId,
498
+ platform,
499
+ runtimeVersion: input.runtimeVersion,
500
+ message: input.message,
501
+ expoConfig,
502
+ metadataJson,
503
+ publicApiBase: input.publicApiBase,
504
+ });
505
+ if (payload)
506
+ platforms.push(payload);
507
+ }
508
+ if (!platforms.length)
509
+ throw new Error(`No ${input.platform} bundle found in ${exportDir}.`);
510
+ const { data } = await input.client.post(`/ubriot/apps/${encodeURIComponent(input.app)}/updates`, {
511
+ channel: input.channel,
512
+ runtimeVersion: input.runtimeVersion,
513
+ message: input.message,
514
+ platforms,
515
+ }, { timeout: 120000 });
516
+ return { data, platforms };
517
+ }
518
+ // ─── Auth ────────────────────────────────────────────────────────────────────
519
+ commander_1.program
520
+ .command("login")
521
+ .description("Log in with your Ubriot credentials")
522
+ .option("--api-base <url>", "API base URL")
523
+ .action(async (opts) => {
524
+ if (opts.apiBase)
525
+ (0, config_1.writeConfig)({ apiBase: opts.apiBase });
526
+ const email = await (0, readline_1.readlineSync)("Email: ");
527
+ const password = await (0, readline_1.readlineSync)("Password: ", true);
528
+ try {
529
+ const client = (0, api_1.makeClient)();
530
+ const { data } = await client.post("/auth/login", { email, password });
531
+ const token = data?.user?.token;
532
+ if (!token)
533
+ throw new Error("No token in response");
534
+ (0, config_1.writeConfig)({ token, email });
535
+ console.log(`Logged in as ${email}`);
536
+ }
537
+ catch (err) {
538
+ (0, api_1.handleError)(err);
539
+ }
540
+ });
541
+ commander_1.program
542
+ .command("logout")
543
+ .description("Clear saved credentials")
544
+ .action(() => {
545
+ (0, config_1.writeConfig)({ token: null, email: null });
546
+ console.log("Logged out.");
547
+ });
548
+ commander_1.program
549
+ .command("whoami")
550
+ .description("Show current user")
551
+ .action(() => {
552
+ const cfg = (0, config_1.readConfig)();
553
+ if (cfg.email)
554
+ console.log(cfg.email);
555
+ else
556
+ console.log("Not logged in.");
557
+ });
558
+ // ─── Apps ────────────────────────────────────────────────────────────────────
559
+ const apps = commander_1.program.command("apps").description("Manage release apps");
560
+ apps
561
+ .command("create")
562
+ .description("Create a new release app")
563
+ .requiredOption("--name <name>")
564
+ .requiredOption("--slug <slug>")
565
+ .option("--repo <url>", "Git repository URL")
566
+ .option("--branch <branch>", "Default branch", "main")
567
+ .option("--android-id <id>", "Android application ID")
568
+ .option("--ios-bundle <id>", "iOS bundle identifier")
569
+ .option("--google-play-track <track>", "Default Google Play track", "internal")
570
+ .action(async (opts) => {
571
+ (0, config_1.requireAuth)();
572
+ try {
573
+ const { data } = await (0, api_1.makeClient)().post("/ubriot/apps", {
574
+ name: opts.name,
575
+ slug: opts.slug,
576
+ repoUrl: opts.repo,
577
+ defaultBranch: opts.branch,
578
+ androidApplicationId: opts.androidId,
579
+ iosBundleIdentifier: opts.iosBundle,
580
+ googlePlayTrack: opts.googlePlayTrack,
581
+ });
582
+ console.log(`Created app: ${data.name} (id: ${data.id}, slug: ${data.slug})`);
583
+ }
584
+ catch (err) {
585
+ (0, api_1.handleError)(err);
586
+ }
587
+ });
588
+ apps
589
+ .command("list")
590
+ .description("List your release apps")
591
+ .action(async () => {
592
+ (0, config_1.requireAuth)();
593
+ try {
594
+ const { data } = await (0, api_1.makeClient)().get("/ubriot/apps");
595
+ if (!data.length) {
596
+ console.log("No apps yet.");
597
+ return;
598
+ }
599
+ for (const a of data) {
600
+ const asc = a.appStoreConnectAppId ? ` ascApp=${a.appStoreConnectAppId}` : "";
601
+ const auto = a.autoSubmitTestflight ? " autoSubmit" : "";
602
+ console.log(` ${a.id} ${a.slug.padEnd(30)} ${a.name}${asc}${auto}`);
603
+ }
604
+ }
605
+ catch (err) {
606
+ (0, api_1.handleError)(err);
607
+ }
608
+ });
609
+ apps
610
+ .command("update")
611
+ .description("Update release app settings (TestFlight mapping, auto-submit)")
612
+ .requiredOption("--app <appId>", "Release app id")
613
+ .option("--ios-bundle <id>", "iOS bundle identifier")
614
+ .option("--android-id <id>", "Android application ID / package name")
615
+ .option("--asc-app-id <id>", "App Store Connect app id (from ASC app URL)")
616
+ .option("--clear-asc-app-id", "Remove stored ASC app id (use bundle id lookup)", false)
617
+ .option("--auto-submit-testflight", "Enable auto-submit after each successful iOS build", false)
618
+ .option("--no-auto-submit-testflight", "Disable auto-submit", false)
619
+ .option("--google-play-track <track>", "Default Google Play track, e.g. internal, alpha, beta, production")
620
+ .option("--auto-submit-google-play", "Enable auto-submit to Google Play after each successful Android build", false)
621
+ .option("--no-auto-submit-google-play", "Disable Google Play auto-submit", false)
622
+ .action(async (opts) => {
623
+ (0, config_1.requireAuth)();
624
+ try {
625
+ const body = {};
626
+ if (opts.iosBundle !== undefined)
627
+ body.iosBundleIdentifier = opts.iosBundle || null;
628
+ if (opts.androidId !== undefined)
629
+ body.androidApplicationId = opts.androidId || null;
630
+ if (opts.clearAscAppId)
631
+ body.appStoreConnectAppId = null;
632
+ else if (opts.ascAppId !== undefined)
633
+ body.appStoreConnectAppId = opts.ascAppId;
634
+ if (opts.autoSubmitTestflight)
635
+ body.autoSubmitTestflight = true;
636
+ if (opts.noAutoSubmitTestflight)
637
+ body.autoSubmitTestflight = false;
638
+ if (opts.googlePlayTrack !== undefined)
639
+ body.googlePlayTrack = opts.googlePlayTrack || null;
640
+ if (opts.autoSubmitGooglePlay)
641
+ body.autoSubmitGooglePlay = true;
642
+ if (opts.noAutoSubmitGooglePlay)
643
+ body.autoSubmitGooglePlay = false;
644
+ if (!Object.keys(body).length) {
645
+ console.log("Nothing to update — pass --ios-bundle, --android-id, --asc-app-id, --google-play-track, and/or auto-submit flags");
646
+ return;
647
+ }
648
+ const { data } = await (0, api_1.makeClient)().patch(`/ubriot/apps/${opts.app}`, body);
649
+ console.log(`Updated app #${data.id} ${data.slug}`);
650
+ }
651
+ catch (err) {
652
+ (0, api_1.handleError)(err);
653
+ }
654
+ });
655
+ // ─── Releases ────────────────────────────────────────────────────────────────
656
+ commander_1.program
657
+ .command("ship <platform>")
658
+ .description("Build and submit a store release")
659
+ .option("--app <appIdOrSlug>", "App ID or slug; defaults to expo.slug from app.json")
660
+ .option("--profile <profile>", "Build profile", "production")
661
+ .option("--repo <url>", "Override repo URL")
662
+ .option("--branch <branch>", "Branch to build")
663
+ .option("--track <track>", "Google Play track for Android", "internal")
664
+ .option("--no-submit", "Build only; do not submit to TestFlight / Google Play")
665
+ .option("--poll-interval <seconds>", "Polling interval while waiting", "15")
666
+ .option("--timeout <minutes>", "Maximum wait time for the build and store submit", "180")
667
+ .action(async (platform, opts) => {
668
+ (0, config_1.requireAuth)();
669
+ try {
670
+ const normalizedPlatform = String(platform).toLowerCase();
671
+ if (normalizedPlatform !== "ios" && normalizedPlatform !== "android") {
672
+ throw new Error("Use: ubriot ship ios or ubriot ship android");
673
+ }
674
+ if (normalizedPlatform === "android" && opts.submit !== false && opts.profile === "preview") {
675
+ throw new Error("Google Play submission requires a production AAB. Use --no-submit for preview Android builds.");
676
+ }
677
+ const expoConfig = readExpoConfig();
678
+ const app = resolveAppSlug(opts.app, expoConfig);
679
+ const client = (0, api_1.makeClient)();
680
+ const pollIntervalMs = parsePositiveNumber(opts.pollInterval, 15, "--poll-interval") * 1000;
681
+ const timeoutMs = parsePositiveNumber(opts.timeout, 180, "--timeout") * 60 * 1000;
682
+ const { data } = await client.post(`/ubriot/apps/${encodeURIComponent(app)}/builds`, {
683
+ platform: normalizedPlatform,
684
+ profile: opts.profile,
685
+ repoUrl: opts.repo,
686
+ branch: opts.branch,
687
+ });
688
+ console.log(`Queued build #${data.id} (${data.platform} / ${data.profile}) — status: ${data.status}`);
689
+ const finished = await waitForBuild(client, data.id, pollIntervalMs, timeoutMs);
690
+ if (opts.submit === false)
691
+ return;
692
+ if (normalizedPlatform === "ios") {
693
+ const submitted = await queueTestFlightIfNeeded(client, finished);
694
+ await waitForTestFlight(client, submitted.id, pollIntervalMs, timeoutMs);
695
+ console.log(`Build #${submitted.id} is valid in TestFlight.`);
696
+ }
697
+ else {
698
+ const submitted = await queueGooglePlayIfNeeded(client, finished, opts.track);
699
+ await waitForGooglePlay(client, submitted.id, pollIntervalMs, timeoutMs);
700
+ console.log(`Build #${submitted.id} was submitted to Google Play track "${submitted.playTrack ?? opts.track}".`);
701
+ }
702
+ }
703
+ catch (err) {
704
+ (0, api_1.handleError)(err);
705
+ }
706
+ });
707
+ // ─── Builds ──────────────────────────────────────────────────────────────────
708
+ const builds = commander_1.program.command("builds").description("Manage builds");
709
+ builds
710
+ .command("create")
711
+ .description("Queue a build")
712
+ .requiredOption("--app <appId>", "App ID or slug")
713
+ .requiredOption("--platform <platform>", "android | ios | web")
714
+ .option("--profile <profile>", "Build profile", "production")
715
+ .option("--repo <url>", "Override repo URL")
716
+ .option("--branch <branch>", "Branch to build")
717
+ .option("--wait", "Wait until the build reaches a terminal state", false)
718
+ .option("--submit-testflight", "For iOS: wait for the build, submit to TestFlight, and wait for App Store Connect", false)
719
+ .option("--submit-google-play", "For Android: wait for the build, submit to Google Play, and wait for commit", false)
720
+ .option("--track <track>", "Google Play track for --submit-google-play", "internal")
721
+ .option("--poll-interval <seconds>", "Polling interval while waiting", "15")
722
+ .option("--timeout <minutes>", "Maximum wait time for the build and store submit", "180")
723
+ .action(async (opts) => {
724
+ (0, config_1.requireAuth)();
725
+ try {
726
+ const client = (0, api_1.makeClient)();
727
+ const pollIntervalMs = parsePositiveNumber(opts.pollInterval, 15, "--poll-interval") * 1000;
728
+ const timeoutMs = parsePositiveNumber(opts.timeout, 180, "--timeout") * 60 * 1000;
729
+ const shouldWait = Boolean(opts.wait || opts.submitTestflight || opts.submitGooglePlay);
730
+ if (opts.submitTestflight && opts.platform !== "ios") {
731
+ throw new Error("--submit-testflight can only be used with --platform ios");
732
+ }
733
+ if (opts.submitGooglePlay && opts.platform !== "android") {
734
+ throw new Error("--submit-google-play can only be used with --platform android");
735
+ }
736
+ if (opts.submitGooglePlay && opts.profile === "preview") {
737
+ throw new Error("--submit-google-play requires a production AAB. Use --wait without --submit-google-play for preview Android builds.");
738
+ }
739
+ const { data } = await client.post(`/ubriot/apps/${opts.app}/builds`, {
740
+ platform: opts.platform,
741
+ profile: opts.profile,
742
+ repoUrl: opts.repo,
743
+ branch: opts.branch,
744
+ });
745
+ console.log(`Queued build #${data.id} (${data.platform} / ${data.profile}) — status: ${data.status}`);
746
+ if (!shouldWait) {
747
+ if (data.platform === "ios") {
748
+ console.log(`To submit after it succeeds: ubriot builds submit-testflight ${data.id}`);
749
+ }
750
+ if (data.platform === "android") {
751
+ console.log(`To submit after it succeeds: ubriot builds submit-google-play ${data.id}`);
752
+ }
753
+ return;
754
+ }
755
+ const finished = await waitForBuild(client, data.id, pollIntervalMs, timeoutMs);
756
+ if (opts.submitGooglePlay) {
757
+ const submitted = await queueGooglePlayIfNeeded(client, finished, opts.track);
758
+ await waitForGooglePlay(client, submitted.id, pollIntervalMs, timeoutMs);
759
+ console.log(`Build #${submitted.id} was submitted to Google Play track "${submitted.playTrack ?? opts.track}".`);
760
+ return;
761
+ }
762
+ if (!opts.submitTestflight)
763
+ return;
764
+ const submitted = await queueTestFlightIfNeeded(client, finished);
765
+ await waitForTestFlight(client, submitted.id, pollIntervalMs, timeoutMs);
766
+ console.log(`Build #${submitted.id} is valid in TestFlight.`);
767
+ }
768
+ catch (err) {
769
+ (0, api_1.handleError)(err);
770
+ }
771
+ });
772
+ builds
773
+ .command("list")
774
+ .description("List recent builds")
775
+ .option("--app <appId>", "Filter by app ID")
776
+ .action(async (opts) => {
777
+ (0, config_1.requireAuth)();
778
+ try {
779
+ const url = opts.app ? `/ubriot/apps/${opts.app}/builds` : "/ubriot/builds";
780
+ const { data } = await (0, api_1.makeClient)().get(url);
781
+ if (!data.length) {
782
+ console.log("No builds found.");
783
+ return;
784
+ }
785
+ for (const b of data) {
786
+ const asc = b.ascSubmissionStatus ? ` asc:${b.ascSubmissionStatus}` : "";
787
+ console.log(` #${String(b.id).padEnd(5)} ${b.platform.padEnd(10)} ${b.profile.padEnd(15)} ${b.status.padEnd(25)}${asc} ${b.createdAt}`);
788
+ }
789
+ }
790
+ catch (err) {
791
+ (0, api_1.handleError)(err);
792
+ }
793
+ });
794
+ builds
795
+ .command("show <buildId>")
796
+ .description("Show build details")
797
+ .action(async (buildId) => {
798
+ (0, config_1.requireAuth)();
799
+ try {
800
+ const { data } = await (0, api_1.makeClient)().get(`/ubriot/builds/${buildId}`);
801
+ console.log(JSON.stringify(data, null, 2));
802
+ }
803
+ catch (err) {
804
+ (0, api_1.handleError)(err);
805
+ }
806
+ });
807
+ builds
808
+ .command("logs <buildId>")
809
+ .description("Show build logs")
810
+ .action(async (buildId) => {
811
+ (0, config_1.requireAuth)();
812
+ try {
813
+ const { data } = await (0, api_1.makeClient)().get(`/ubriot/builds/${buildId}/logs`);
814
+ for (const l of data)
815
+ process.stdout.write(`[${l.phase ?? "-"}] ${l.message}`);
816
+ }
817
+ catch (err) {
818
+ (0, api_1.handleError)(err);
819
+ }
820
+ });
821
+ builds
822
+ .command("cancel <buildId>")
823
+ .description("Cancel a queued/running build")
824
+ .action(async (buildId) => {
825
+ (0, config_1.requireAuth)();
826
+ try {
827
+ await (0, api_1.makeClient)().post(`/ubriot/builds/${buildId}/cancel`);
828
+ console.log(`Build #${buildId} cancelled.`);
829
+ }
830
+ catch (err) {
831
+ (0, api_1.handleError)(err);
832
+ }
833
+ });
834
+ builds
835
+ .command("retry-upload <buildId>")
836
+ .description("Retry only the artifact upload stage for a failed mobile build")
837
+ .action(async (buildId) => {
838
+ (0, config_1.requireAuth)();
839
+ try {
840
+ await (0, api_1.makeClient)().post(`/ubriot/builds/${buildId}/retry-upload`);
841
+ console.log(`Build #${buildId} queued for upload retry.`);
842
+ }
843
+ catch (err) {
844
+ (0, api_1.handleError)(err);
845
+ }
846
+ });
847
+ builds
848
+ .command("submit-testflight <buildId>")
849
+ .description("Queue upload of the build .ipa to App Store Connect (TestFlight); requires ASC API key on the app")
850
+ .action(async (buildId) => {
851
+ (0, config_1.requireAuth)();
852
+ try {
853
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/builds/${buildId}/submit-testflight`);
854
+ console.log(`Build #${buildId} queued for TestFlight submit — ascSubmissionStatus: ${data.ascSubmissionStatus}`);
855
+ }
856
+ catch (err) {
857
+ (0, api_1.handleError)(err);
858
+ }
859
+ });
860
+ builds
861
+ .command("reset-testflight <buildId>")
862
+ .description("Clear a queued or running TestFlight submission so you can retry or unstick after a worker crash")
863
+ .action(async (buildId) => {
864
+ (0, config_1.requireAuth)();
865
+ try {
866
+ await (0, api_1.makeClient)().post(`/ubriot/builds/${buildId}/reset-testflight-submit`);
867
+ console.log(`Build #${buildId}: TestFlight submission reset.`);
868
+ }
869
+ catch (err) {
870
+ (0, api_1.handleError)(err);
871
+ }
872
+ });
873
+ builds
874
+ .command("submit-google-play <buildId>")
875
+ .description("Queue upload of the Android .aab to Google Play; requires a Google service account on the app")
876
+ .option("--track <track>", "Google Play track", "internal")
877
+ .action(async (buildId, opts) => {
878
+ (0, config_1.requireAuth)();
879
+ try {
880
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/builds/${buildId}/submit-google-play`, { track: opts.track });
881
+ console.log(`Build #${buildId} queued for Google Play submit — playSubmissionStatus: ${data.playSubmissionStatus}`);
882
+ }
883
+ catch (err) {
884
+ (0, api_1.handleError)(err);
885
+ }
886
+ });
887
+ builds
888
+ .command("reset-google-play <buildId>")
889
+ .description("Clear a queued or running Google Play submission so you can retry")
890
+ .action(async (buildId) => {
891
+ (0, config_1.requireAuth)();
892
+ try {
893
+ await (0, api_1.makeClient)().post(`/ubriot/builds/${buildId}/reset-google-play-submit`);
894
+ console.log(`Build #${buildId}: Google Play submission reset.`);
895
+ }
896
+ catch (err) {
897
+ (0, api_1.handleError)(err);
898
+ }
899
+ });
900
+ builds
901
+ .command("artifacts <buildId>")
902
+ .description("List artifacts for a build")
903
+ .action(async (buildId) => {
904
+ (0, config_1.requireAuth)();
905
+ try {
906
+ const { data } = await (0, api_1.makeClient)().get(`/ubriot/builds/${buildId}/artifacts`);
907
+ if (!data.length) {
908
+ console.log("No artifacts yet.");
909
+ return;
910
+ }
911
+ for (const a of data)
912
+ console.log(` #${a.id} ${a.kind.padEnd(10)} ${a.fileName} (${a.sizeBytes} bytes)`);
913
+ }
914
+ catch (err) {
915
+ (0, api_1.handleError)(err);
916
+ }
917
+ });
918
+ // ─── Credentials ─────────────────────────────────────────────────────────────
919
+ const creds = commander_1.program.command("credentials").description("Manage signing credentials");
920
+ creds
921
+ .command("upload-android")
922
+ .description("Upload an Android keystore credential")
923
+ .requiredOption("--app <appId>")
924
+ .requiredOption("--name <name>")
925
+ .requiredOption("--keystore <path>", "Path to .keystore or .jks file")
926
+ .requiredOption("--keystore-password <pwd>")
927
+ .requiredOption("--key-alias <alias>")
928
+ .requiredOption("--key-password <pwd>")
929
+ .action(async (opts) => {
930
+ (0, config_1.requireAuth)();
931
+ try {
932
+ const keystoreBase64 = (0, fs_1.readFileSync)(opts.keystore).toString("base64");
933
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/credentials`, {
934
+ platform: "android",
935
+ kind: "android-keystore",
936
+ name: opts.name,
937
+ payload: {
938
+ keystoreBase64,
939
+ keystorePassword: opts.keystorePassword,
940
+ keyAlias: opts.keyAlias,
941
+ keyPassword: opts.keyPassword,
942
+ },
943
+ });
944
+ console.log(`Credential created: #${data.id} "${data.name}"`);
945
+ }
946
+ catch (err) {
947
+ (0, api_1.handleError)(err);
948
+ }
949
+ });
950
+ creds
951
+ .command("upload-google-service-account")
952
+ .description("Upload a Google Play service account JSON key (for Android internal/track submit)")
953
+ .requiredOption("--app <appId>")
954
+ .requiredOption("--name <name>")
955
+ .requiredOption("--json <path>", "Path to Google service account JSON")
956
+ .action(async (opts) => {
957
+ (0, config_1.requireAuth)();
958
+ try {
959
+ const serviceAccountJsonBase64 = (0, fs_1.readFileSync)(opts.json).toString("base64");
960
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/credentials`, {
961
+ platform: "android",
962
+ kind: "google-service-account",
963
+ name: opts.name,
964
+ payload: { serviceAccountJsonBase64 },
965
+ });
966
+ console.log(`Google Play service account credential created: #${data.id} "${data.name}"`);
967
+ console.log(`Ready to ship — run: ubriot ship android`);
968
+ }
969
+ catch (err) {
970
+ (0, api_1.handleError)(err);
971
+ }
972
+ });
973
+ creds
974
+ .command("upload-apns-key")
975
+ .description("Upload an APNs auth key for native iOS push notifications")
976
+ .requiredOption("--app <appId>")
977
+ .requiredOption("--name <name>")
978
+ .requiredOption("--key-id <id>", "APNs key ID from Apple Developer")
979
+ .requiredOption("--team-id <id>", "Apple Developer Team ID")
980
+ .requiredOption("--bundle-id <id>", "iOS bundle identifier (e.g. com.company.app)")
981
+ .requiredOption("--key-p8 <path>", "Path to the APNs .p8 private key file")
982
+ .option("--environment <env>", "APNs environment: production or sandbox", "production")
983
+ .action(async (opts) => {
984
+ (0, config_1.requireAuth)();
985
+ try {
986
+ const environment = String(opts.environment).toLowerCase();
987
+ if (environment !== "production" && environment !== "sandbox") {
988
+ throw new Error("--environment must be production or sandbox");
989
+ }
990
+ const privateKeyP8Base64 = (0, fs_1.readFileSync)(opts.keyP8).toString("base64");
991
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/credentials`, {
992
+ platform: "ios",
993
+ kind: "apns-auth-key",
994
+ name: opts.name,
995
+ payload: {
996
+ keyId: opts.keyId,
997
+ teamId: opts.teamId,
998
+ bundleId: opts.bundleId,
999
+ privateKeyP8Base64,
1000
+ environment,
1001
+ },
1002
+ });
1003
+ console.log(`APNs push credential created: #${data.id} "${data.name}"`);
1004
+ }
1005
+ catch (err) {
1006
+ (0, api_1.handleError)(err);
1007
+ }
1008
+ });
1009
+ creds
1010
+ .command("upload-firebase-service-account")
1011
+ .description("Upload a Firebase service account JSON key for native Android push notifications")
1012
+ .requiredOption("--app <appId>")
1013
+ .requiredOption("--name <name>")
1014
+ .requiredOption("--json <path>", "Path to Firebase service account JSON")
1015
+ .action(async (opts) => {
1016
+ (0, config_1.requireAuth)();
1017
+ try {
1018
+ const serviceAccountJsonBase64 = (0, fs_1.readFileSync)(opts.json).toString("base64");
1019
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/credentials`, {
1020
+ platform: "android",
1021
+ kind: "firebase-service-account",
1022
+ name: opts.name,
1023
+ payload: { serviceAccountJsonBase64 },
1024
+ });
1025
+ console.log(`Firebase push credential created: #${data.id} "${data.name}"`);
1026
+ }
1027
+ catch (err) {
1028
+ (0, api_1.handleError)(err);
1029
+ }
1030
+ });
1031
+ creds
1032
+ .command("list")
1033
+ .description("List credentials for an app")
1034
+ .requiredOption("--app <appId>")
1035
+ .action(async (opts) => {
1036
+ (0, config_1.requireAuth)();
1037
+ try {
1038
+ const { data } = await (0, api_1.makeClient)().get(`/ubriot/apps/${opts.app}/credentials`);
1039
+ if (!data.length) {
1040
+ console.log("No credentials.");
1041
+ return;
1042
+ }
1043
+ for (const c of data)
1044
+ console.log(` #${c.id} ${c.kind.padEnd(35)} ${c.name}`);
1045
+ }
1046
+ catch (err) {
1047
+ (0, api_1.handleError)(err);
1048
+ }
1049
+ });
1050
+ creds
1051
+ .command("upload-ios-cert")
1052
+ .description("Upload an iOS distribution certificate (.p12)")
1053
+ .requiredOption("--app <appId>")
1054
+ .requiredOption("--name <name>", "A label for this credential")
1055
+ .requiredOption("--p12 <path>", "Path to the .p12 certificate file")
1056
+ .requiredOption("--p12-password <pwd>", "Password to unlock the .p12")
1057
+ .requiredOption("--team-id <id>", "Apple Developer Team ID (10-char alphanumeric)")
1058
+ .action(async (opts) => {
1059
+ (0, config_1.requireAuth)();
1060
+ try {
1061
+ const p12Base64 = (0, fs_1.readFileSync)(opts.p12).toString("base64");
1062
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/credentials`, {
1063
+ platform: "ios",
1064
+ kind: "ios-distribution-certificate",
1065
+ name: opts.name,
1066
+ payload: {
1067
+ p12Base64,
1068
+ p12Password: opts.p12Password,
1069
+ teamId: opts.teamId,
1070
+ },
1071
+ });
1072
+ console.log(`iOS certificate credential created: #${data.id} "${data.name}"`);
1073
+ console.log(`Team ID: ${opts.teamId}`);
1074
+ }
1075
+ catch (err) {
1076
+ (0, api_1.handleError)(err);
1077
+ }
1078
+ });
1079
+ creds
1080
+ .command("upload-ios-profile")
1081
+ .description("Upload an iOS provisioning profile (.mobileprovision)")
1082
+ .requiredOption("--app <appId>")
1083
+ .requiredOption("--name <name>", "Must match the profile name exactly in Apple Developer portal")
1084
+ .requiredOption("--profile <path>", "Path to the .mobileprovision file")
1085
+ .requiredOption("--bundle-id <id>", "iOS bundle identifier (e.g. com.company.app)")
1086
+ .action(async (opts) => {
1087
+ (0, config_1.requireAuth)();
1088
+ try {
1089
+ const mobileProvisionBase64 = (0, fs_1.readFileSync)(opts.profile).toString("base64");
1090
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/credentials`, {
1091
+ platform: "ios",
1092
+ kind: "ios-provisioning-profile",
1093
+ name: opts.name,
1094
+ payload: {
1095
+ mobileProvisionBase64,
1096
+ name: opts.name,
1097
+ bundleIdentifier: opts.bundleId,
1098
+ },
1099
+ });
1100
+ console.log(`iOS provisioning profile credential created: #${data.id} "${data.name}"`);
1101
+ }
1102
+ catch (err) {
1103
+ (0, api_1.handleError)(err);
1104
+ }
1105
+ });
1106
+ creds
1107
+ .command("upload-asc-key")
1108
+ .description("Upload an App Store Connect API key (for TestFlight / store submission)")
1109
+ .requiredOption("--app <appId>")
1110
+ .requiredOption("--name <name>")
1111
+ .requiredOption("--key-id <id>", "Key ID from App Store Connect")
1112
+ .requiredOption("--issuer-id <id>", "Issuer ID from App Store Connect")
1113
+ .requiredOption("--key-p8 <path>", "Path to the .p8 private key file")
1114
+ .action(async (opts) => {
1115
+ (0, config_1.requireAuth)();
1116
+ try {
1117
+ const privateKeyP8Base64 = (0, fs_1.readFileSync)(opts.keyP8).toString("base64");
1118
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/credentials`, {
1119
+ platform: "ios",
1120
+ kind: "app-store-connect-api-key",
1121
+ name: opts.name,
1122
+ payload: {
1123
+ keyId: opts.keyId,
1124
+ issuerId: opts.issuerId,
1125
+ privateKeyP8Base64,
1126
+ },
1127
+ });
1128
+ console.log(`App Store Connect API key created: #${data.id} "${data.name}"`);
1129
+ }
1130
+ catch (err) {
1131
+ (0, api_1.handleError)(err);
1132
+ }
1133
+ });
1134
+ creds
1135
+ .command("upload-apple-id")
1136
+ .description("Upload Apple ID + app-specific password for TestFlight submission (simpler than ASC API key)")
1137
+ .requiredOption("--app <appId>")
1138
+ .requiredOption("--name <name>")
1139
+ .requiredOption("--apple-id <email>", "Your Apple developer email")
1140
+ .requiredOption("--app-specific-password <xxxx-xxxx-xxxx-xxxx>", "App-specific password from appleid.apple.com")
1141
+ .action(async (opts) => {
1142
+ (0, config_1.requireAuth)();
1143
+ try {
1144
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/credentials`, {
1145
+ platform: "ios",
1146
+ kind: "apple-id-credentials",
1147
+ name: opts.name,
1148
+ payload: {
1149
+ appleId: opts.appleId,
1150
+ appSpecificPassword: opts.appSpecificPassword,
1151
+ },
1152
+ });
1153
+ console.log(`Apple ID credential created: #${data.id} "${data.name}"`);
1154
+ console.log(`Ready to ship — run: ubriot ship ios`);
1155
+ }
1156
+ catch (err) {
1157
+ (0, api_1.handleError)(err);
1158
+ }
1159
+ });
1160
+ creds
1161
+ .command("setup-testflight")
1162
+ .description("Interactive guided setup for TestFlight submission (like EAS — walks you through it)")
1163
+ .requiredOption("--app <appId>", "App ID (run 'ubriot apps list' if unsure)")
1164
+ .action(async (opts) => {
1165
+ (0, config_1.requireAuth)();
1166
+ try {
1167
+ // 1. Confirm the app exists
1168
+ const { data: app } = await (0, api_1.makeClient)().get(`/ubriot/apps/${opts.app}`);
1169
+ console.log(`\n🚀 Setting up TestFlight submission for: ${app.name}`);
1170
+ console.log(` Bundle ID: ${app.iosBundleIdentifier || "(not set)"}\n`);
1171
+ console.log("We need your Apple credentials to submit to TestFlight.");
1172
+ console.log("You only do this ONCE — the same credentials work for all your apps.");
1173
+ console.log("Apple requires a special one-time password for scripts — your regular");
1174
+ console.log("Apple ID password won't work here (Apple blocks it for security).\n");
1175
+ // 2. Get Apple ID
1176
+ const appleId = await (0, readline_1.readlineSync)("Enter your Apple developer email: ");
1177
+ if (!appleId.includes("@")) {
1178
+ console.error("That doesn't look like a valid email. Run the command again.");
1179
+ process.exit(1);
1180
+ }
1181
+ // 3. Guide them to generate an app-specific password
1182
+ console.log(`
1183
+ Next, generate an app-specific password:
1184
+ 1. Open https://appleid.apple.com in your browser
1185
+ 2. Sign in with ${appleId}
1186
+ 3. Go to Security → App-Specific Passwords → click +
1187
+ 4. Name it "Ubriot" and click Create
1188
+ 5. Copy the password Apple shows you (looks like xxxx-xxxx-xxxx-xxxx)
1189
+ `);
1190
+ // 4. Collect the password (hidden input)
1191
+ const appSpecificPassword = await (0, readline_1.readlineSync)("Paste the app-specific password here: ", true);
1192
+ if (!appSpecificPassword.match(/^[a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4}$/)) {
1193
+ console.log("\n⚠ That doesn't look like the expected format (xxxx-xxxx-xxxx-xxxx).");
1194
+ console.log(" Saving anyway — if the format was wrong the submit step will tell you.\n");
1195
+ }
1196
+ // 5. Save credential
1197
+ const { data: cred } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/credentials`, {
1198
+ platform: "ios",
1199
+ kind: "apple-id-credentials",
1200
+ name: "Apple ID (TestFlight)",
1201
+ payload: { appleId, appSpecificPassword },
1202
+ });
1203
+ console.log(`\n✅ Apple credentials saved (#${cred.id}).`);
1204
+ console.log(` These will be used automatically for all your apps — no setup needed for future apps.`);
1205
+ console.log(`\nTo build and submit to TestFlight, run:`);
1206
+ console.log(` ubriot ship ios\n`);
1207
+ }
1208
+ catch (err) {
1209
+ (0, api_1.handleError)(err);
1210
+ }
1211
+ });
1212
+ creds
1213
+ .command("upload-github-token")
1214
+ .description("Upload a GitHub personal access token (for private repo cloning)")
1215
+ .requiredOption("--app <appId>")
1216
+ .requiredOption("--name <name>")
1217
+ .requiredOption("--token <ghp_xxx>", "GitHub personal access token with repo scope")
1218
+ .action(async (opts) => {
1219
+ (0, config_1.requireAuth)();
1220
+ try {
1221
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/credentials`, {
1222
+ platform: "any",
1223
+ kind: "github-token",
1224
+ name: opts.name,
1225
+ payload: { token: opts.token },
1226
+ });
1227
+ console.log(`GitHub token credential created: #${data.id} "${data.name}"`);
1228
+ }
1229
+ catch (err) {
1230
+ (0, api_1.handleError)(err);
1231
+ }
1232
+ });
1233
+ creds
1234
+ .command("delete <credId>")
1235
+ .description("Delete a credential")
1236
+ .action(async (credId) => {
1237
+ (0, config_1.requireAuth)();
1238
+ try {
1239
+ await (0, api_1.makeClient)().delete(`/ubriot/credentials/${credId}`);
1240
+ console.log(`Credential #${credId} deleted.`);
1241
+ }
1242
+ catch (err) {
1243
+ (0, api_1.handleError)(err);
1244
+ }
1245
+ });
1246
+ // ─── Push ────────────────────────────────────────────────────────────────────
1247
+ const push = commander_1.program.command("push").description("Manage Ubriot Push");
1248
+ push
1249
+ .command("setup")
1250
+ .description("Guided setup for Ubriot Push credentials and backend key")
1251
+ .requiredOption("--app <appIdOrSlug>", "Release app id or slug")
1252
+ .option("--project-dir <dir>", "Expo project directory; searched automatically when omitted")
1253
+ .option("--expo-config <path>", "Path to app.json relative to --project-dir")
1254
+ .option("--platform <platform>", "ios, android, or all", "all")
1255
+ .option("--name <name>", "Credential name prefix", "Ubriot Push")
1256
+ .option("--ios-bundle <id>", "iOS bundle identifier")
1257
+ .option("--android-package <id>", "Android package name")
1258
+ .option("--apns-key-id <id>", "APNs key ID from Apple Developer")
1259
+ .option("--apns-team-id <id>", "Apple Developer Team ID")
1260
+ .option("--apns-key-p8 <path>", "Path to APNs .p8 private key")
1261
+ .option("--apns-environment <env>", "APNs environment: production or sandbox", "production")
1262
+ .option("--firebase-service-account <path>", "Path to Firebase service account JSON")
1263
+ .option("--server-key-name <name>", "Backend server key label", "Backend")
1264
+ .option("--skip-server-key", "Do not create or inspect a backend push server key", false)
1265
+ .option("--new-server-key", "Create a new backend push server key even if one already exists", false)
1266
+ .option("--force-credentials", "Upload push provider credentials even when one already exists", false)
1267
+ .action(async (opts) => {
1268
+ (0, config_1.requireAuth)();
1269
+ try {
1270
+ const client = (0, api_1.makeClient)();
1271
+ const app = await resolveReleaseApp(client, opts.app);
1272
+ const appKey = encodeURIComponent(String(app.id));
1273
+ const discovery = discoverExpoConfig({ projectDir: opts.projectDir, expoConfig: opts.expoConfig, app: app.slug ?? opts.app });
1274
+ const expoConfig = discovery.config;
1275
+ const platforms = normalizePushPlatforms(opts.platform);
1276
+ const credentialPrefix = String(opts.name ?? "Ubriot Push");
1277
+ console.log(`Setting up Ubriot Push for ${app.name ?? opts.app} (${app.slug ?? opts.app})`);
1278
+ if (discovery.configPath) {
1279
+ console.log(`Expo config: ${(0, path_1.relative)(process.cwd(), discovery.configPath)}`);
1280
+ }
1281
+ else {
1282
+ console.log("Expo config: not found; pass --project-dir or --expo-config to improve auto-detection.");
1283
+ }
1284
+ const iosBundleId = opts.iosBundle ?? app.iosBundleIdentifier ?? expoConfig?.ios?.bundleIdentifier;
1285
+ const androidPackage = opts.androidPackage ?? app.androidApplicationId ?? expoConfig?.android?.package;
1286
+ const appPatch = {};
1287
+ if (iosBundleId && !app.iosBundleIdentifier)
1288
+ appPatch.iosBundleIdentifier = iosBundleId;
1289
+ if (androidPackage && !app.androidApplicationId)
1290
+ appPatch.androidApplicationId = androidPackage;
1291
+ if (Object.keys(appPatch).length) {
1292
+ await client.patch(`/ubriot/apps/${appKey}`, appPatch);
1293
+ console.log("Synced native app identifiers to Ubriot.");
1294
+ }
1295
+ if (iosBundleId && app.iosBundleIdentifier && app.iosBundleIdentifier !== iosBundleId) {
1296
+ console.log(`Warning: Ubriot app has iOS bundle ${app.iosBundleIdentifier}, but local config has ${iosBundleId}.`);
1297
+ }
1298
+ if (androidPackage && app.androidApplicationId && app.androidApplicationId !== androidPackage) {
1299
+ console.log(`Warning: Ubriot app has Android package ${app.androidApplicationId}, but local config has ${androidPackage}.`);
1300
+ }
1301
+ const { data: credentials } = await client.get(`/ubriot/apps/${appKey}/credentials`);
1302
+ const hasCredential = (kind) => credentials.some((credential) => credential.kind === kind);
1303
+ const completed = [];
1304
+ const skipped = [];
1305
+ if (platforms.includes("ios")) {
1306
+ if (hasCredential("apns-auth-key") && !opts.forceCredentials) {
1307
+ skipped.push("iOS APNs credential already exists");
1308
+ }
1309
+ else {
1310
+ const environment = String(opts.apnsEnvironment ?? "production").toLowerCase();
1311
+ if (environment !== "production" && environment !== "sandbox") {
1312
+ throw new Error("--apns-environment must be production or sandbox");
1313
+ }
1314
+ let apnsKeyP8 = resolveExistingPath(opts.apnsKeyP8, discovery.projectDir) ?? findApnsKeyP8(discovery.projectDir);
1315
+ if (!apnsKeyP8) {
1316
+ apnsKeyP8 = await promptOptionalPath("APNs .p8 path", undefined, "--apns-key-p8", discovery.projectDir);
1317
+ }
1318
+ if (!apnsKeyP8)
1319
+ throw new Error("Missing APNs .p8 path. Create/download the key in Apple Developer, then rerun with --apns-key-p8 <path>.");
1320
+ const bundleId = await promptRequired("iOS bundle identifier", iosBundleId, "--ios-bundle <id>");
1321
+ const keyId = await promptRequired("APNs key ID", opts.apnsKeyId ?? inferApnsKeyId(apnsKeyP8), "--apns-key-id <id>");
1322
+ const teamId = await promptRequired("Apple Team ID", opts.apnsTeamId, "--apns-team-id <id>");
1323
+ const privateKeyP8Base64 = (0, fs_1.readFileSync)(apnsKeyP8).toString("base64");
1324
+ const { data } = await client.post(`/ubriot/apps/${appKey}/credentials`, {
1325
+ platform: "ios",
1326
+ kind: "apns-auth-key",
1327
+ name: `${credentialPrefix} APNs`,
1328
+ payload: {
1329
+ keyId,
1330
+ teamId,
1331
+ bundleId,
1332
+ privateKeyP8Base64,
1333
+ environment,
1334
+ },
1335
+ });
1336
+ completed.push(`iOS APNs credential #${data.id}`);
1337
+ }
1338
+ }
1339
+ if (platforms.includes("android")) {
1340
+ if (hasCredential("firebase-service-account") && !opts.forceCredentials) {
1341
+ skipped.push("Android Firebase credential already exists");
1342
+ }
1343
+ else {
1344
+ let firebaseServiceAccount = resolveExistingPath(opts.firebaseServiceAccount, discovery.projectDir) ?? findFirebaseServiceAccount(discovery.projectDir);
1345
+ if (!firebaseServiceAccount) {
1346
+ firebaseServiceAccount = await promptOptionalPath("Firebase service account JSON path", undefined, "--firebase-service-account <path>", discovery.projectDir);
1347
+ }
1348
+ if (!firebaseServiceAccount) {
1349
+ skipped.push("Android Firebase credential not uploaded");
1350
+ }
1351
+ else {
1352
+ if (!looksLikeFirebaseServiceAccount(firebaseServiceAccount)) {
1353
+ throw new Error(`${firebaseServiceAccount} is not a Firebase service account JSON key.`);
1354
+ }
1355
+ const serviceAccountJsonBase64 = (0, fs_1.readFileSync)(firebaseServiceAccount).toString("base64");
1356
+ const { data } = await client.post(`/ubriot/apps/${appKey}/credentials`, {
1357
+ platform: "android",
1358
+ kind: "firebase-service-account",
1359
+ name: `${credentialPrefix} Firebase`,
1360
+ payload: { serviceAccountJsonBase64 },
1361
+ });
1362
+ completed.push(`Android Firebase credential #${data.id}`);
1363
+ }
1364
+ }
1365
+ }
1366
+ let serverKey;
1367
+ if (opts.skipServerKey) {
1368
+ skipped.push("backend push server key");
1369
+ }
1370
+ else {
1371
+ const { data: pushKeys } = await client.get(`/ubriot/apps/${appKey}/push/server-keys`);
1372
+ const activeKeys = pushKeys.filter((key) => !key.revokedAt);
1373
+ if (activeKeys.length && !opts.newServerKey) {
1374
+ skipped.push(`backend push server key already exists (${activeKeys.length} active)`);
1375
+ }
1376
+ else {
1377
+ const { data } = await client.post(`/ubriot/apps/${appKey}/push/server-keys`, { name: opts.serverKeyName });
1378
+ serverKey = data.key;
1379
+ completed.push(`backend push server key #${data.id}`);
1380
+ }
1381
+ }
1382
+ console.log("");
1383
+ if (completed.length) {
1384
+ console.log("Completed:");
1385
+ for (const item of completed)
1386
+ console.log(` - ${item}`);
1387
+ }
1388
+ if (skipped.length) {
1389
+ console.log("Skipped:");
1390
+ for (const item of skipped)
1391
+ console.log(` - ${item}`);
1392
+ }
1393
+ if (serverKey) {
1394
+ console.log("");
1395
+ console.log("Set this on the app backend:");
1396
+ console.log(`UBRIOT_PUSH_SERVER_KEY=${serverKey}`);
1397
+ console.log("");
1398
+ console.log("This key is shown once. Store it in the backend environment now.");
1399
+ }
1400
+ else {
1401
+ console.log("");
1402
+ console.log("No new server key was printed. Existing server keys cannot be revealed; pass --new-server-key if the backend needs a fresh value.");
1403
+ }
1404
+ }
1405
+ catch (err) {
1406
+ (0, api_1.handleError)(err);
1407
+ }
1408
+ });
1409
+ push
1410
+ .command("create-server-key")
1411
+ .description("Create an app-scoped server key for sending Ubriot Push messages")
1412
+ .requiredOption("--app <appId>")
1413
+ .option("--name <name>", "A label for this server key", "Backend")
1414
+ .action(async (opts) => {
1415
+ (0, config_1.requireAuth)();
1416
+ try {
1417
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/push/server-keys`, { name: opts.name });
1418
+ console.log(`Push server key created: #${data.id} ${data.keyPrefix}…`);
1419
+ console.log("");
1420
+ console.log("Set this on your backend only:");
1421
+ console.log(`UBRIOT_PUSH_SERVER_KEY=${data.key}`);
1422
+ console.log("");
1423
+ console.log("This key is only shown once. Create a new one if it is lost.");
1424
+ }
1425
+ catch (err) {
1426
+ (0, api_1.handleError)(err);
1427
+ }
1428
+ });
1429
+ push
1430
+ .command("list-server-keys")
1431
+ .description("List app-scoped Ubriot Push server keys")
1432
+ .requiredOption("--app <appId>")
1433
+ .action(async (opts) => {
1434
+ (0, config_1.requireAuth)();
1435
+ try {
1436
+ const { data } = await (0, api_1.makeClient)().get(`/ubriot/apps/${opts.app}/push/server-keys`);
1437
+ if (!data.length) {
1438
+ console.log("No push server keys.");
1439
+ return;
1440
+ }
1441
+ for (const key of data) {
1442
+ const state = key.revokedAt ? "revoked" : "active";
1443
+ console.log(` #${key.id} ${key.keyPrefix}… ${state} ${key.name}`);
1444
+ }
1445
+ }
1446
+ catch (err) {
1447
+ (0, api_1.handleError)(err);
1448
+ }
1449
+ });
1450
+ push
1451
+ .command("revoke-server-key <keyId>")
1452
+ .description("Revoke a Ubriot Push server key")
1453
+ .action(async (keyId) => {
1454
+ (0, config_1.requireAuth)();
1455
+ try {
1456
+ await (0, api_1.makeClient)().delete(`/ubriot/push/server-keys/${keyId}`);
1457
+ console.log(`Push server key #${keyId} revoked.`);
1458
+ }
1459
+ catch (err) {
1460
+ (0, api_1.handleError)(err);
1461
+ }
1462
+ });
1463
+ // ─── Channels ────────────────────────────────────────────────────────────────
1464
+ const channels = commander_1.program.command("channels").description("Manage OTA channels");
1465
+ channels
1466
+ .command("list")
1467
+ .requiredOption("--app <appId>")
1468
+ .action(async (opts) => {
1469
+ (0, config_1.requireAuth)();
1470
+ try {
1471
+ const { data } = await (0, api_1.makeClient)().get(`/ubriot/apps/${opts.app}/channels`);
1472
+ if (!data.length) {
1473
+ console.log("No channels.");
1474
+ return;
1475
+ }
1476
+ for (const c of data)
1477
+ console.log(` ${c.name.padEnd(20)} current group: ${c.currentUpdateGroupId ?? "none"}`);
1478
+ }
1479
+ catch (err) {
1480
+ (0, api_1.handleError)(err);
1481
+ }
1482
+ });
1483
+ channels
1484
+ .command("create")
1485
+ .requiredOption("--app <appId>")
1486
+ .requiredOption("--name <name>")
1487
+ .action(async (opts) => {
1488
+ (0, config_1.requireAuth)();
1489
+ try {
1490
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/apps/${opts.app}/channels`, { name: opts.name });
1491
+ console.log(`Channel "${data.name}" created (id: ${data.id})`);
1492
+ }
1493
+ catch (err) {
1494
+ (0, api_1.handleError)(err);
1495
+ }
1496
+ });
1497
+ channels
1498
+ .command("point")
1499
+ .description("Point a channel to an update group")
1500
+ .requiredOption("--app <appId>")
1501
+ .requiredOption("--channel <name>")
1502
+ .requiredOption("--update-group <id>")
1503
+ .action(async (opts) => {
1504
+ (0, config_1.requireAuth)();
1505
+ try {
1506
+ await (0, api_1.makeClient)().patch(`/ubriot/apps/${opts.app}/channels/${opts.channel}`, {
1507
+ updateGroupId: Number(opts.updateGroup),
1508
+ });
1509
+ console.log(`Channel "${opts.channel}" now points to update group #${opts.updateGroup}`);
1510
+ }
1511
+ catch (err) {
1512
+ (0, api_1.handleError)(err);
1513
+ }
1514
+ });
1515
+ commander_1.program
1516
+ .command("update")
1517
+ .description("Export and publish an OTA update from the current Expo app")
1518
+ .option("--app <appIdOrSlug>", "App ID or slug; defaults to expo.slug from app.json")
1519
+ .option("--channel <channel>", "OTA channel", "production")
1520
+ .option("--runtime-version <version>", "Runtime version; defaults to runtimeVersion from app.json")
1521
+ .option("--message <msg>", "Release note; defaults to the current git short SHA")
1522
+ .option("--platform <platform>", "ios, android, or all", "all")
1523
+ .option("--export-dir <dir>", "Expo export output directory", "dist")
1524
+ .option("--expo-config <path>", "Path to app.json or exported Expo config JSON", "app.json")
1525
+ .option("--public-url <url>", "Public API base URL used in generated asset URLs")
1526
+ .option("--no-export", "Publish an existing export directory without running expo export")
1527
+ .action(async (opts) => {
1528
+ (0, config_1.requireAuth)();
1529
+ try {
1530
+ const expoConfig = readExpoConfig(opts.expoConfig);
1531
+ const app = resolveAppSlug(opts.app, expoConfig);
1532
+ const runtimeVersion = resolveRuntimeVersion(opts.runtimeVersion, expoConfig);
1533
+ const message = opts.message ?? optionalGitShortSha();
1534
+ const client = (0, api_1.makeClient)();
1535
+ const publicApiBase = (opts.publicUrl || (0, config_1.readConfig)().apiBase).replace(/\/$/, "");
1536
+ if (opts.export !== false) {
1537
+ runLocalCommand("npx", ["expo", "export", "--platform", opts.platform, "--output-dir", opts.exportDir]);
1538
+ }
1539
+ const { data, platforms } = await publishExportedUpdate({
1540
+ client,
1541
+ app,
1542
+ channel: opts.channel,
1543
+ runtimeVersion,
1544
+ message,
1545
+ exportDir: opts.exportDir,
1546
+ expoConfigPath: opts.expoConfig,
1547
+ platform: opts.platform,
1548
+ publicApiBase,
1549
+ });
1550
+ console.log(`Published update group #${data.updateGroupId} on channel "${data.channel}" (${platforms.map((p) => p.platform).join(", ")})`);
1551
+ }
1552
+ catch (err) {
1553
+ (0, api_1.handleError)(err);
1554
+ }
1555
+ });
1556
+ // ─── Updates ─────────────────────────────────────────────────────────────────
1557
+ const updates = commander_1.program.command("updates").description("Manage OTA updates");
1558
+ updates
1559
+ .command("list")
1560
+ .requiredOption("--app <appId>")
1561
+ .action(async (opts) => {
1562
+ (0, config_1.requireAuth)();
1563
+ try {
1564
+ const { data } = await (0, api_1.makeClient)().get(`/ubriot/apps/${opts.app}/updates`);
1565
+ if (!data.length) {
1566
+ console.log("No updates.");
1567
+ return;
1568
+ }
1569
+ for (const u of data)
1570
+ console.log(` #${u.id} channel: ${(u.channelName ?? "-").padEnd(15)} msg: ${u.message ?? ""}`);
1571
+ }
1572
+ catch (err) {
1573
+ (0, api_1.handleError)(err);
1574
+ }
1575
+ });
1576
+ updates
1577
+ .command("publish")
1578
+ .description("Publish an OTA update (wrap expo export output)")
1579
+ .requiredOption("--app <appIdOrSlug>")
1580
+ .requiredOption("--channel <channel>")
1581
+ .requiredOption("--runtime-version <version>")
1582
+ .option("--message <msg>")
1583
+ .option("--export-dir <dir>", "Path to npx expo export output directory")
1584
+ .option("--expo-config <path>", "Path to app.json or exported Expo config JSON", "app.json")
1585
+ .option("--platform <platform>", "ios, android, or all when using --export-dir", "all")
1586
+ .option("--public-url <url>", "Public API base URL used in generated asset URLs")
1587
+ .option("--manifest-android <path>", "Path to android manifest JSON")
1588
+ .option("--manifest-ios <path>", "Path to ios manifest JSON")
1589
+ .action(async (opts) => {
1590
+ (0, config_1.requireAuth)();
1591
+ try {
1592
+ const platforms = [];
1593
+ const client = (0, api_1.makeClient)();
1594
+ const publicApiBase = (opts.publicUrl || (0, config_1.readConfig)().apiBase).replace(/\/$/, "");
1595
+ if (opts.exportDir) {
1596
+ const exportDir = (0, path_1.resolve)(opts.exportDir);
1597
+ const metadataPath = (0, path_1.join)(exportDir, "metadata.json");
1598
+ if (!(0, fs_1.existsSync)(metadataPath))
1599
+ throw new Error(`No metadata.json found in ${exportDir}. Run: npx expo export --output-dir ${opts.exportDir}`);
1600
+ const metadataJson = readJsonFile(metadataPath);
1601
+ const configJson = (0, fs_1.existsSync)((0, path_1.resolve)(opts.expoConfig)) ? readJsonFile((0, path_1.resolve)(opts.expoConfig)) : {};
1602
+ const expoConfig = configJson.expo ?? configJson;
1603
+ const publishId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1604
+ const selectedPlatforms = opts.platform === "all" ? ["ios", "android"] : [opts.platform];
1605
+ for (const platform of selectedPlatforms) {
1606
+ if (platform !== "ios" && platform !== "android")
1607
+ throw new Error("--platform must be ios, android, or all");
1608
+ const payload = await buildOtaPlatformPayload({
1609
+ client,
1610
+ app: opts.app,
1611
+ exportDir,
1612
+ publishId,
1613
+ platform,
1614
+ runtimeVersion: opts.runtimeVersion,
1615
+ message: opts.message,
1616
+ expoConfig,
1617
+ metadataJson,
1618
+ publicApiBase,
1619
+ });
1620
+ if (payload)
1621
+ platforms.push(payload);
1622
+ }
1623
+ }
1624
+ else {
1625
+ if (opts.manifestAndroid) {
1626
+ const manifestJson = (0, fs_1.readFileSync)(opts.manifestAndroid, "utf8");
1627
+ platforms.push({ platform: "android", manifestJson, assets: [] });
1628
+ }
1629
+ if (opts.manifestIos) {
1630
+ const manifestJson = (0, fs_1.readFileSync)(opts.manifestIos, "utf8");
1631
+ platforms.push({ platform: "ios", manifestJson, assets: [] });
1632
+ }
1633
+ }
1634
+ if (!platforms.length) {
1635
+ console.error("Provide --export-dir, or at least one of --manifest-android / --manifest-ios");
1636
+ process.exit(1);
1637
+ }
1638
+ const { data } = await client.post(`/ubriot/apps/${encodeURIComponent(opts.app)}/updates`, {
1639
+ channel: opts.channel,
1640
+ runtimeVersion: opts.runtimeVersion,
1641
+ message: opts.message,
1642
+ platforms,
1643
+ }, { timeout: 120000 });
1644
+ console.log(`Published update group #${data.updateGroupId} on channel "${data.channel}" (${platforms.map((p) => p.platform).join(", ")})`);
1645
+ }
1646
+ catch (err) {
1647
+ (0, api_1.handleError)(err);
1648
+ }
1649
+ });
1650
+ commander_1.program.parse(process.argv);