@beamdeploy/cli 0.2.0-preview.1

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.
Files changed (3) hide show
  1. package/README.md +22 -0
  2. package/dist/cli.cjs +922 -0
  3. package/package.json +46 -0
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @beamdeploy/cli
2
+
3
+ BeamDeploy's Node.js 20+ command-line client for validating and uploading immutable OTA bundles,
4
+ uploading React Native platform bundles, and promoting a tested version between channels.
5
+
6
+ ```bash
7
+ npm install --save-dev @beamdeploy/cli
8
+ npx beamdeploy --help
9
+ ```
10
+
11
+ Set the control-plane origin and an app-scoped deploy key through CI secrets:
12
+
13
+ ```bash
14
+ export BEAMDEPLOY_SERVER="https://beamdeploy.example.com"
15
+ export BEAMDEPLOY_TOKEN="<app-scoped deploy key>"
16
+ npx beamdeploy doctor --server "$BEAMDEPLOY_SERVER"
17
+ npx beamdeploy auth context
18
+ ```
19
+
20
+ Never commit the deploy key or ship it in a mobile application. Use `rn upload --dry-run` to
21
+ validate a React Native artifact locally before the authenticated staging upload. Inspect
22
+ `npx beamdeploy --help` before using optional preflight or channel commands.
package/dist/cli.cjs ADDED
@@ -0,0 +1,922 @@
1
+ #!/usr/bin/env node
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") {
10
+ for (let key of __getOwnPropNames(from))
11
+ if (!__hasOwnProp.call(to, key) && key !== except)
12
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
+ }
14
+ return to;
15
+ };
16
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
17
+ // If the importer is in node compatibility mode or this is not an ESM
18
+ // file that has been converted to a CommonJS file using a Babel-
19
+ // compatible transform (i.e. "__esModule" has not been set), then set
20
+ // "default" to the CommonJS "module.exports" for node compatibility.
21
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
22
+ mod
23
+ ));
24
+
25
+ // ../../scripts/beamdeploy-cli.mjs
26
+ var import_promises2 = require("node:fs/promises");
27
+ var import_node_os = require("node:os");
28
+ var import_node_path2 = __toESM(require("node:path"), 1);
29
+
30
+ // ../../src/bundle-delta.js
31
+ var import_yauzl = __toESM(require("yauzl"), 1);
32
+
33
+ // ../../src/zip-pack.js
34
+ function crc32(buffer) {
35
+ let crc = 4294967295;
36
+ for (let i = 0; i < buffer.length; i += 1) {
37
+ crc ^= buffer[i];
38
+ for (let j = 0; j < 8; j += 1) {
39
+ crc = crc >>> 1 ^ (crc & 1 ? 3988292384 : 0);
40
+ }
41
+ }
42
+ return (crc ^ 4294967295) >>> 0;
43
+ }
44
+ function zipEntry(fileName, body) {
45
+ const name = Buffer.from(fileName);
46
+ const checksum = crc32(body);
47
+ const localHeader = Buffer.alloc(30);
48
+ localHeader.writeUInt32LE(67324752, 0);
49
+ localHeader.writeUInt16LE(20, 4);
50
+ localHeader.writeUInt16LE(0, 6);
51
+ localHeader.writeUInt16LE(0, 8);
52
+ localHeader.writeUInt16LE(0, 10);
53
+ localHeader.writeUInt16LE(0, 12);
54
+ localHeader.writeUInt32LE(checksum, 14);
55
+ localHeader.writeUInt32LE(body.length, 18);
56
+ localHeader.writeUInt32LE(body.length, 22);
57
+ localHeader.writeUInt16LE(name.length, 26);
58
+ localHeader.writeUInt16LE(0, 28);
59
+ const centralHeader = Buffer.alloc(46);
60
+ centralHeader.writeUInt32LE(33639248, 0);
61
+ centralHeader.writeUInt16LE(20, 4);
62
+ centralHeader.writeUInt16LE(20, 6);
63
+ centralHeader.writeUInt16LE(0, 8);
64
+ centralHeader.writeUInt16LE(0, 10);
65
+ centralHeader.writeUInt16LE(0, 12);
66
+ centralHeader.writeUInt16LE(0, 14);
67
+ centralHeader.writeUInt32LE(checksum, 16);
68
+ centralHeader.writeUInt32LE(body.length, 20);
69
+ centralHeader.writeUInt32LE(body.length, 24);
70
+ centralHeader.writeUInt16LE(name.length, 28);
71
+ centralHeader.writeUInt16LE(0, 30);
72
+ centralHeader.writeUInt16LE(0, 32);
73
+ centralHeader.writeUInt16LE(0, 34);
74
+ centralHeader.writeUInt16LE(0, 36);
75
+ centralHeader.writeUInt32LE(0, 38);
76
+ centralHeader.writeUInt32LE(0, 42);
77
+ const centralOffset = localHeader.length + name.length + body.length;
78
+ const centralSize = centralHeader.length + name.length;
79
+ const end = Buffer.alloc(22);
80
+ end.writeUInt32LE(101010256, 0);
81
+ end.writeUInt16LE(0, 4);
82
+ end.writeUInt16LE(0, 6);
83
+ end.writeUInt16LE(1, 8);
84
+ end.writeUInt16LE(1, 10);
85
+ end.writeUInt32LE(centralSize, 12);
86
+ end.writeUInt32LE(centralOffset, 16);
87
+ end.writeUInt16LE(0, 20);
88
+ return {
89
+ local: Buffer.concat([localHeader, name, body]),
90
+ central: Buffer.concat([centralHeader, name]),
91
+ centralSize,
92
+ centralOffset
93
+ };
94
+ }
95
+ function packZip(entries) {
96
+ if (!entries.length) {
97
+ throw new Error("packZip requires at least one entry.");
98
+ }
99
+ const parts = [];
100
+ const centrals = [];
101
+ let offset = 0;
102
+ for (const entry of entries) {
103
+ const zipped = zipEntry(entry.name, entry.data);
104
+ parts.push(zipped.local);
105
+ centrals.push({ ...zipped, offset });
106
+ offset += zipped.local.length;
107
+ }
108
+ let centralSize = 0;
109
+ for (const central of centrals) {
110
+ const patched = Buffer.from(central.central);
111
+ patched.writeUInt32LE(central.offset, 42);
112
+ parts.push(patched);
113
+ centralSize += patched.length;
114
+ }
115
+ const end = Buffer.alloc(22);
116
+ end.writeUInt32LE(101010256, 0);
117
+ end.writeUInt16LE(0, 4);
118
+ end.writeUInt16LE(0, 6);
119
+ end.writeUInt16LE(centrals.length, 8);
120
+ end.writeUInt16LE(centrals.length, 10);
121
+ end.writeUInt32LE(centralSize, 12);
122
+ end.writeUInt32LE(offset, 16);
123
+ end.writeUInt16LE(0, 20);
124
+ parts.push(end);
125
+ return Buffer.concat(parts);
126
+ }
127
+
128
+ // ../../src/bundle-delta.js
129
+ var DELTA_MANIFEST_PATH = "_beamdeploy/delta.json";
130
+ var DELTA_FORMAT = "beamdeploy-delta-v1";
131
+ var DELTA_UPLOAD_RATIO_THRESHOLD = 0.9;
132
+ function unsafeZipPath(fileName) {
133
+ return fileName.startsWith("/") || fileName.includes("\\") || fileName.split("/").filter(Boolean).some((part) => part === "..");
134
+ }
135
+ function normalizePath(fileName) {
136
+ return fileName.replace(/\\/g, "/");
137
+ }
138
+ function readZipFileMap(buffer) {
139
+ return new Promise((resolve, reject) => {
140
+ const files = /* @__PURE__ */ new Map();
141
+ import_yauzl.default.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (error, zipfile) => {
142
+ if (error || !zipfile) {
143
+ reject(new Error("Unable to read bundle zip."));
144
+ return;
145
+ }
146
+ const pending = [];
147
+ zipfile.on("entry", (entry) => {
148
+ if (unsafeZipPath(entry.fileName) || /\/$/.test(entry.fileName)) {
149
+ zipfile.readEntry();
150
+ return;
151
+ }
152
+ pending.push(
153
+ new Promise((entryResolve, entryReject) => {
154
+ zipfile.openReadStream(entry, (streamError, readStream) => {
155
+ if (streamError || !readStream) {
156
+ entryReject(streamError || new Error("Unable to read zip entry."));
157
+ return;
158
+ }
159
+ const chunks = [];
160
+ readStream.on("data", (chunk) => chunks.push(chunk));
161
+ readStream.on("end", () => {
162
+ files.set(normalizePath(entry.fileName), Buffer.concat(chunks));
163
+ entryResolve();
164
+ });
165
+ readStream.on("error", entryReject);
166
+ });
167
+ })
168
+ );
169
+ zipfile.readEntry();
170
+ });
171
+ zipfile.on("end", async () => {
172
+ zipfile.close();
173
+ try {
174
+ await Promise.all(pending);
175
+ resolve(files);
176
+ } catch (entryError) {
177
+ reject(entryError);
178
+ }
179
+ });
180
+ zipfile.on("error", reject);
181
+ zipfile.readEntry();
182
+ });
183
+ });
184
+ }
185
+ function computeDeltaFromFileMaps(baseFiles, newFiles, baseVersion) {
186
+ const deleted = [];
187
+ const changed = /* @__PURE__ */ new Map();
188
+ for (const [path3] of baseFiles) {
189
+ if (path3 === DELTA_MANIFEST_PATH) {
190
+ continue;
191
+ }
192
+ if (!newFiles.has(path3)) {
193
+ deleted.push(path3);
194
+ }
195
+ }
196
+ for (const [path3, body] of newFiles) {
197
+ if (path3 === DELTA_MANIFEST_PATH) {
198
+ continue;
199
+ }
200
+ const previous = baseFiles.get(path3);
201
+ if (!previous || !previous.equals(body)) {
202
+ changed.set(path3, body);
203
+ }
204
+ }
205
+ if (!changed.size && !deleted.length) {
206
+ throw new Error("No file changes detected between base and new bundle.");
207
+ }
208
+ const manifest = {
209
+ format: DELTA_FORMAT,
210
+ baseVersion,
211
+ delete: deleted
212
+ };
213
+ const entries = [{ name: DELTA_MANIFEST_PATH, data: Buffer.from(JSON.stringify(manifest), "utf8") }];
214
+ for (const [path3, body] of [...changed.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
215
+ entries.push({ name: path3, data: body });
216
+ }
217
+ const deltaZip = packZip(entries);
218
+ return {
219
+ deltaZip,
220
+ deleted,
221
+ changedCount: changed.size,
222
+ deletedCount: deleted.length
223
+ };
224
+ }
225
+ async function buildDeltaZipFromBundles(baseBuffer, newBuffer, baseVersion) {
226
+ const baseFiles = await readZipFileMap(baseBuffer);
227
+ const newFiles = await readZipFileMap(newBuffer);
228
+ return computeDeltaFromFileMaps(baseFiles, newFiles, baseVersion);
229
+ }
230
+ function shouldPreferFullUpload(deltaSize, fullSize, threshold = DELTA_UPLOAD_RATIO_THRESHOLD) {
231
+ if (!fullSize || fullSize <= 0) {
232
+ return true;
233
+ }
234
+ return deltaSize >= fullSize * threshold;
235
+ }
236
+
237
+ // ../../src/rn-upload.js
238
+ var import_node_crypto = require("node:crypto");
239
+ var import_promises = require("node:fs/promises");
240
+ var import_node_path = __toESM(require("node:path"), 1);
241
+
242
+ // ../../src/bundle-platform.js
243
+ var MOBILE_PLATFORMS = /* @__PURE__ */ new Set(["ios", "android"]);
244
+ var BUNDLE_PLATFORMS = /* @__PURE__ */ new Set([...MOBILE_PLATFORMS, "electron"]);
245
+ function normalizeBundlePlatform(value) {
246
+ const platform = String(value || "").trim().toLowerCase();
247
+ return BUNDLE_PLATFORMS.has(platform) ? platform : null;
248
+ }
249
+
250
+ // ../../src/rn-upload.js
251
+ var IOS_BUNDLE_NAMES = ["bundle/main.jsbundle", "main.jsbundle"];
252
+ var ANDROID_BUNDLE_NAMES = ["bundle/index.android.bundle", "index.android.bundle"];
253
+ function normalizeRnVersionSlug(rnVersion) {
254
+ const match = String(rnVersion || "").trim().match(/(\d+)\.(\d+)/);
255
+ if (!match) {
256
+ return "unknown";
257
+ }
258
+ return `${match[1]}${match[2]}`;
259
+ }
260
+ function slugAbi(value) {
261
+ return String(value || "default").trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "default";
262
+ }
263
+ function deriveNativeBuildId({
264
+ rnVersion,
265
+ hermesEnabled = true,
266
+ nativeModulesAbi = "default",
267
+ nativeBuildId
268
+ } = {}) {
269
+ const override = String(nativeBuildId || "").trim();
270
+ if (override) {
271
+ return override;
272
+ }
273
+ const rnSlug = normalizeRnVersionSlug(rnVersion);
274
+ const engine = hermesEnabled ? "hermes" : "jsc";
275
+ const abi = slugAbi(nativeModulesAbi);
276
+ const fingerprint = `${rnVersion}|${hermesEnabled}|${abi}`;
277
+ const digest = (0, import_node_crypto.createHash)("sha256").update(fingerprint).digest("hex").slice(0, 8);
278
+ return `rn${rnSlug}-${engine}-${abi}-${digest}`;
279
+ }
280
+ function sha256Hex(buffer) {
281
+ return (0, import_node_crypto.createHash)("sha256").update(buffer).digest("hex");
282
+ }
283
+ function rnBundleZipEntryName(platform) {
284
+ return platform === "ios" ? "bundle/main.jsbundle" : "bundle/index.android.bundle";
285
+ }
286
+ function rnSourcemapZipEntryName(platform) {
287
+ return platform === "ios" ? "bundle/main.jsbundle.map" : "bundle/index.android.bundle.map";
288
+ }
289
+ async function fileExists(filePath) {
290
+ try {
291
+ const info = await (0, import_promises.stat)(filePath);
292
+ return info.isFile();
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+ async function findRnBundleSource(bundleDir, platform) {
298
+ const names = platform === "ios" ? IOS_BUNDLE_NAMES : ANDROID_BUNDLE_NAMES;
299
+ for (const rel of names) {
300
+ const candidate = import_node_path.default.join(bundleDir, rel);
301
+ if (await fileExists(candidate)) {
302
+ return { sourcePath: candidate, zipPath: rnBundleZipEntryName(platform) };
303
+ }
304
+ }
305
+ throw new Error(
306
+ `Could not find ${platform === "ios" ? "main.jsbundle" : "index.android.bundle"} under ${bundleDir}. Expected one of: ${names.join(", ")}`
307
+ );
308
+ }
309
+ async function collectAssetEntries(assetsDir) {
310
+ const entries = [];
311
+ async function walk(dir, prefix) {
312
+ const names = await (0, import_promises.readdir)(dir);
313
+ for (const name of names) {
314
+ const full = import_node_path.default.join(dir, name);
315
+ const rel = prefix ? `${prefix}/${name}` : name;
316
+ const info = await (0, import_promises.stat)(full);
317
+ if (info.isDirectory()) {
318
+ await walk(full, rel);
319
+ } else {
320
+ entries.push({
321
+ name: `assets/${rel}`,
322
+ data: await (0, import_promises.readFile)(full)
323
+ });
324
+ }
325
+ }
326
+ }
327
+ await walk(assetsDir, "");
328
+ return entries;
329
+ }
330
+ async function readReactNativeVersion(projectDir) {
331
+ try {
332
+ const raw = await (0, import_promises.readFile)(import_node_path.default.join(projectDir, "package.json"), "utf8");
333
+ const pkg = JSON.parse(raw);
334
+ const value = pkg.dependencies?.["react-native"] || pkg.devDependencies?.["react-native"] || pkg.peerDependencies?.["react-native"] || "";
335
+ return String(value).replace(/^[\^~>=<]+/, "").trim();
336
+ } catch {
337
+ return "";
338
+ }
339
+ }
340
+ function buildRnCompatibility({ rnVersion, hermesEnabled, nativeBuildId }) {
341
+ return {
342
+ runtime: "react-native",
343
+ rnVersion: String(rnVersion || "").trim(),
344
+ hermesEnabled: Boolean(hermesEnabled),
345
+ nativeBuildId: String(nativeBuildId || "").trim()
346
+ };
347
+ }
348
+ function buildRnUploadQuery({ channel, platform, compatibility }) {
349
+ const params = new URLSearchParams();
350
+ if (channel) {
351
+ params.set("channel", channel);
352
+ }
353
+ const normalizedPlatform = normalizeBundlePlatform(platform);
354
+ if (normalizedPlatform) {
355
+ params.set("platform", normalizedPlatform);
356
+ }
357
+ if (compatibility?.runtime) {
358
+ params.set("runtime", compatibility.runtime);
359
+ }
360
+ if (compatibility?.nativeBuildId) {
361
+ params.set("nativeBuildId", compatibility.nativeBuildId);
362
+ }
363
+ if (compatibility?.rnVersion) {
364
+ params.set("rnVersion", compatibility.rnVersion);
365
+ }
366
+ if (compatibility?.hermesEnabled !== void 0) {
367
+ params.set("hermesEnabled", compatibility.hermesEnabled ? "true" : "false");
368
+ }
369
+ return params.toString();
370
+ }
371
+ async function packRnOtaZip({
372
+ bundleDir,
373
+ bundlePath,
374
+ sourcemapPath,
375
+ platform,
376
+ rnVersion,
377
+ hermesEnabled = true,
378
+ nativeModulesAbi = "default",
379
+ nativeBuildId: nativeBuildIdOverride
380
+ }) {
381
+ const normalizedPlatform = normalizeBundlePlatform(platform);
382
+ if (!normalizedPlatform) {
383
+ throw new Error("platform must be ios or android");
384
+ }
385
+ const zipPath = rnBundleZipEntryName(normalizedPlatform);
386
+ let bundleData;
387
+ if (bundlePath) {
388
+ const resolvedBundle = import_node_path.default.resolve(bundlePath);
389
+ if (!await fileExists(resolvedBundle)) {
390
+ throw new Error(`Bundle file not found: ${resolvedBundle}`);
391
+ }
392
+ bundleData = await (0, import_promises.readFile)(resolvedBundle);
393
+ } else {
394
+ if (!bundleDir) {
395
+ throw new Error("bundleDir or bundlePath is required");
396
+ }
397
+ const found = await findRnBundleSource(bundleDir, normalizedPlatform);
398
+ bundleData = await (0, import_promises.readFile)(found.sourcePath);
399
+ }
400
+ const derivedBuildId = deriveNativeBuildId({
401
+ rnVersion,
402
+ hermesEnabled,
403
+ nativeModulesAbi,
404
+ nativeBuildId: nativeBuildIdOverride
405
+ });
406
+ const compatibility = buildRnCompatibility({
407
+ rnVersion,
408
+ hermesEnabled,
409
+ nativeBuildId: derivedBuildId
410
+ });
411
+ const fileChecksums = {
412
+ [zipPath]: sha256Hex(bundleData)
413
+ };
414
+ const zipEntries = [{ name: zipPath, data: bundleData }];
415
+ if (sourcemapPath) {
416
+ const resolvedMap = import_node_path.default.resolve(sourcemapPath);
417
+ if (!await fileExists(resolvedMap)) {
418
+ throw new Error(`Source map not found: ${resolvedMap}`);
419
+ }
420
+ const mapData = await (0, import_promises.readFile)(resolvedMap);
421
+ const mapZipPath = rnSourcemapZipEntryName(normalizedPlatform);
422
+ fileChecksums[mapZipPath] = sha256Hex(mapData);
423
+ zipEntries.push({ name: mapZipPath, data: mapData });
424
+ }
425
+ const assetsDir = import_node_path.default.join(bundleDir || ".", "assets");
426
+ try {
427
+ const assetInfo = await (0, import_promises.stat)(assetsDir);
428
+ if (assetInfo.isDirectory()) {
429
+ const assetEntries = await collectAssetEntries(assetsDir);
430
+ for (const entry of assetEntries) {
431
+ fileChecksums[entry.name] = sha256Hex(entry.data);
432
+ zipEntries.push(entry);
433
+ }
434
+ }
435
+ } catch {
436
+ }
437
+ const manifest = {
438
+ rnVersion: compatibility.rnVersion,
439
+ hermesEnabled: compatibility.hermesEnabled,
440
+ nativeBuildId: compatibility.nativeBuildId,
441
+ platform: normalizedPlatform,
442
+ fileChecksums
443
+ };
444
+ const manifestBody = Buffer.from(JSON.stringify(manifest, null, 2));
445
+ zipEntries.push({ name: "manifest.json", data: manifestBody });
446
+ return {
447
+ zip: packZip(zipEntries),
448
+ manifest,
449
+ nativeBuildId: derivedBuildId,
450
+ compatibility
451
+ };
452
+ }
453
+
454
+ // ../../scripts/beamdeploy-cli.mjs
455
+ var configDir = import_node_path2.default.join((0, import_node_os.homedir)(), ".beamdeploy");
456
+ var configPath = import_node_path2.default.join(configDir, "config.json");
457
+ function usage() {
458
+ console.log(`BeamDeploy CLI
459
+
460
+ Usage:
461
+ beamdeploy init [apiKey]
462
+ beamdeploy login <apiKey>
463
+ beamdeploy auth context
464
+ beamdeploy doctor [--server http://localhost:8787]
465
+ beamdeploy app create <appId> [name]
466
+ beamdeploy bundle upload <appId> <version> <zipPath> [--channel production] [--platform electron] [--delta] [--delta-base <version>] [--preflight]
467
+ beamdeploy bundle patch <appId> <version> [--display-name NAME] [--description TEXT] [--notes TEXT]
468
+ [--min-native V] [--max-native V] [--breaking]
469
+ beamdeploy bundle trash <appId> <version>
470
+ beamdeploy bundle restore <appId> <version>
471
+ beamdeploy channel trash <appId> <channel>
472
+ beamdeploy channel restore <appId> <channel>
473
+ beamdeploy rn upload <appId> <version> [bundleDir] --platform ios|android
474
+ [--bundle <jsbundle>] [--sourcemap <map>] [--deploy-key <token>]
475
+ [--channel production] [--rn-version 0.74.0] [--no-hermes] [--abi arm64-v8a]
476
+ [--native-build-id ID] [--project .] [--dry-run] [--preflight]
477
+ beamdeploy channel promote <appId> <channel> <version> [--preflight]
478
+ beamdeploy channel set <appId> <channel> --bundle <version> [--preflight]
479
+ beamdeploy channel ensure <appId> <channel> [--self-assign] [--preflight]
480
+ beamdeploy stats <appId> [--preflight]
481
+
482
+ Environment:
483
+ BEAMDEPLOY_SERVER API server URL, defaults to http://localhost:8787
484
+ BEAMDEPLOY_TOKEN Admin API key or app deploy key (overridden by --deploy-key)
485
+ `);
486
+ }
487
+ function argValue(args, name, fallback = "") {
488
+ const index = args.indexOf(name);
489
+ return index === -1 ? fallback : args[index + 1] || fallback;
490
+ }
491
+ function hasFlag(args, name) {
492
+ return args.includes(name);
493
+ }
494
+ async function loadConfig() {
495
+ try {
496
+ return JSON.parse(await (0, import_promises2.readFile)(configPath, "utf8"));
497
+ } catch {
498
+ return {};
499
+ }
500
+ }
501
+ async function saveConfig(config) {
502
+ await (0, import_promises2.mkdir)(configDir, { recursive: true });
503
+ await (0, import_promises2.writeFile)(configPath, JSON.stringify(config, null, 2));
504
+ }
505
+ async function request(pathname, options = {}) {
506
+ const config = await loadConfig();
507
+ const server = process.env.BEAMDEPLOY_SERVER || config.server || "http://localhost:8787";
508
+ const token = options.token || process.env.BEAMDEPLOY_TOKEN || config.token || "";
509
+ const headers = {
510
+ ...options.body instanceof Buffer ? {} : { "content-type": "application/json" },
511
+ ...options.headers
512
+ };
513
+ if (token) {
514
+ headers.authorization = `Bearer ${token}`;
515
+ }
516
+ const response = await fetch(`${server}${pathname}`, {
517
+ ...options,
518
+ headers
519
+ });
520
+ const payload = await response.json().catch(() => ({}));
521
+ if (!response.ok) {
522
+ const detail = payload.message ? `${payload.error || response.status}: ${payload.message}` : payload.error || "request failed";
523
+ throw new Error(`${response.status}: ${detail}`);
524
+ }
525
+ return payload;
526
+ }
527
+ var DEPLOY_SCOPE_REQUIREMENTS = {
528
+ upload: "bundle:upload",
529
+ promote: "channel:promote",
530
+ stats: "read:stats"
531
+ };
532
+ function deployKeyAllowsChannelScope(context, channel) {
533
+ if (!context?.channels?.length || context.channelAccess?.includes("*")) {
534
+ return true;
535
+ }
536
+ const normalized = String(channel || "").trim().toLowerCase();
537
+ if (!normalized) {
538
+ return true;
539
+ }
540
+ return context.channels.includes(normalized);
541
+ }
542
+ function assertDeployKeyPreflight(context, { appId, channel, action }) {
543
+ if (!context || context.kind !== "deploy_key") {
544
+ return;
545
+ }
546
+ if (context.appId !== appId) {
547
+ throw new Error(`Deploy key is scoped to app ${context.appId}, not ${appId}.`);
548
+ }
549
+ const requiredScope = DEPLOY_SCOPE_REQUIREMENTS[action];
550
+ if (requiredScope && !(context.scopes || []).includes(requiredScope) && !(context.scopes || []).includes("deploy:admin")) {
551
+ throw new Error(`Deploy key lacks ${requiredScope} scope required for ${action}. Current scopes: ${(context.scopes || []).join(", ") || "none"}.`);
552
+ }
553
+ if (channel && !deployKeyAllowsChannelScope(context, channel)) {
554
+ const allowed = context.channels?.length ? context.channels.join(", ") : "all channels";
555
+ throw new Error(`Deploy key is not allowed for channel "${channel}". Allowed channels: ${allowed}.`);
556
+ }
557
+ }
558
+ async function loadAuthContext(tokenOverride) {
559
+ return request("/v1/auth/context", { token: tokenOverride || void 0 });
560
+ }
561
+ async function resolveDeltaBaseVersion(appId, channel, explicitBase, token) {
562
+ if (explicitBase) {
563
+ return explicitBase;
564
+ }
565
+ const payload = await request(`/v1/apps/${encodeURIComponent(appId)}/channels`, {
566
+ token: token || void 0
567
+ });
568
+ const match = (payload.channels || []).find((row) => row.name === channel);
569
+ if (!match?.bundleVersion) {
570
+ throw new Error(
571
+ `Channel "${channel}" has no bundle to diff against. Upload a full bundle first or pass --delta-base <version>.`
572
+ );
573
+ }
574
+ return match.bundleVersion;
575
+ }
576
+ async function downloadBundleZip(appId, version, token) {
577
+ const config = await loadConfig();
578
+ const server = process.env.BEAMDEPLOY_SERVER || config.server || "http://localhost:8787";
579
+ const authToken = token || process.env.BEAMDEPLOY_TOKEN || config.token || "";
580
+ const headers = {};
581
+ if (authToken) {
582
+ headers.authorization = `Bearer ${authToken}`;
583
+ }
584
+ const response = await fetch(
585
+ `${server}/updates/${encodeURIComponent(appId)}/${encodeURIComponent(version)}.zip`,
586
+ { headers }
587
+ );
588
+ if (!response.ok) {
589
+ throw new Error(`Unable to download base bundle ${version}: HTTP ${response.status}`);
590
+ }
591
+ return Buffer.from(await response.arrayBuffer());
592
+ }
593
+ async function main() {
594
+ const [command, subcommand, ...args] = process.argv.slice(2);
595
+ if (!command || command === "--help" || command === "-h") {
596
+ usage();
597
+ return;
598
+ }
599
+ if (command === "init") {
600
+ const apiKey = subcommand || process.env.BEAMDEPLOY_TOKEN || "";
601
+ const server = process.env.BEAMDEPLOY_SERVER || "http://localhost:8787";
602
+ await (0, import_promises2.writeFile)(
603
+ "beamdeploy.config.json",
604
+ JSON.stringify(
605
+ {
606
+ server,
607
+ appId: "com.example.app",
608
+ channel: "production",
609
+ tokenEnv: "BEAMDEPLOY_TOKEN"
610
+ },
611
+ null,
612
+ 2
613
+ )
614
+ );
615
+ if (apiKey) {
616
+ await saveConfig({ server, token: apiKey });
617
+ }
618
+ console.log("Created beamdeploy.config.json");
619
+ return;
620
+ }
621
+ if (command === "auth" && subcommand === "context") {
622
+ const deployKey = argValue(args, "--deploy-key", "");
623
+ const payload = await loadAuthContext(deployKey || void 0);
624
+ console.log(JSON.stringify(payload, null, 2));
625
+ return;
626
+ }
627
+ if (command === "login") {
628
+ const token = subcommand;
629
+ if (!token) throw new Error("Usage: beamdeploy login <apiKey>");
630
+ await saveConfig({
631
+ server: process.env.BEAMDEPLOY_SERVER || "http://localhost:8787",
632
+ token
633
+ });
634
+ console.log("Saved BeamDeploy API token.");
635
+ return;
636
+ }
637
+ if (command === "doctor") {
638
+ const server = argValue([subcommand, ...args].filter(Boolean), "--server", process.env.BEAMDEPLOY_SERVER || "http://localhost:8787");
639
+ const health = await fetch(`${server}/v1/health`).then((response) => response.json());
640
+ console.log(JSON.stringify(health, null, 2));
641
+ return;
642
+ }
643
+ if (command === "app" && subcommand === "create") {
644
+ const [appId, ...nameParts] = args;
645
+ if (!appId) throw new Error("Usage: beamdeploy app create <appId> [name]");
646
+ const payload = await request("/v1/apps", {
647
+ method: "POST",
648
+ body: JSON.stringify({ appId, name: nameParts.join(" ") || appId })
649
+ });
650
+ console.log(JSON.stringify(payload.app, null, 2));
651
+ return;
652
+ }
653
+ if (command === "rn" && subcommand === "upload") {
654
+ const [appId, version, bundleDirArg, ...rest] = args;
655
+ if (!appId || !version) {
656
+ throw new Error(
657
+ "Usage: beamdeploy rn upload <appId> <version> [bundleDir] --platform ios|android [--bundle <file>] [--sourcemap <file>] [--deploy-key <token>] [--channel production] [--rn-version 0.74.0] [--no-hermes] [--abi arm64-v8a] [--native-build-id ID] [--project .] [--dry-run]"
658
+ );
659
+ }
660
+ const platform = argValue(rest, "--platform", "");
661
+ if (!platform) {
662
+ throw new Error("beamdeploy rn upload requires --platform ios or --platform android");
663
+ }
664
+ const bundlePath = argValue(rest, "--bundle", "");
665
+ const sourcemapPath = argValue(rest, "--sourcemap", "");
666
+ const deployKey = argValue(rest, "--deploy-key", "");
667
+ const channel = argValue(rest, "--channel", "production");
668
+ const bundleDir = bundleDirArg ? import_node_path2.default.resolve(bundleDirArg) : bundlePath ? import_node_path2.default.dirname(import_node_path2.default.resolve(bundlePath)) : process.cwd();
669
+ const projectDir = import_node_path2.default.resolve(argValue(rest, "--project", bundleDir));
670
+ const rnVersion = argValue(rest, "--rn-version", "") || await readReactNativeVersion(projectDir);
671
+ if (!rnVersion) {
672
+ throw new Error("Set --rn-version or add react-native to package.json dependencies.");
673
+ }
674
+ const packed = await packRnOtaZip({
675
+ bundleDir: bundlePath ? bundleDir : import_node_path2.default.resolve(bundleDir),
676
+ bundlePath: bundlePath || void 0,
677
+ sourcemapPath: sourcemapPath || void 0,
678
+ platform,
679
+ rnVersion,
680
+ hermesEnabled: !hasFlag(rest, "--no-hermes"),
681
+ nativeModulesAbi: argValue(rest, "--abi", "default"),
682
+ nativeBuildId: argValue(rest, "--native-build-id", "")
683
+ });
684
+ const query = buildRnUploadQuery({ channel, platform, compatibility: packed.compatibility });
685
+ const uploadPath = `/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(version)}?${query}`;
686
+ const summary = {
687
+ appId,
688
+ version,
689
+ platform,
690
+ channel,
691
+ nativeBuildId: packed.nativeBuildId,
692
+ compatibility: packed.compatibility,
693
+ manifest: packed.manifest,
694
+ zipBytes: packed.zip.length,
695
+ uploadPath
696
+ };
697
+ if (hasFlag(rest, "--dry-run")) {
698
+ console.log(JSON.stringify({ dryRun: true, ...summary }, null, 2));
699
+ return;
700
+ }
701
+ if (hasFlag(rest, "--preflight")) {
702
+ const context = await loadAuthContext(deployKey || void 0);
703
+ assertDeployKeyPreflight(context, { appId, channel, action: "upload" });
704
+ if (channel) {
705
+ assertDeployKeyPreflight(context, { appId, channel, action: "promote" });
706
+ }
707
+ }
708
+ const payload = await request(uploadPath, {
709
+ method: "POST",
710
+ headers: { "content-type": "application/zip" },
711
+ body: packed.zip,
712
+ token: deployKey || void 0
713
+ });
714
+ console.log(JSON.stringify({ ...payload, ...summary }, null, 2));
715
+ return;
716
+ }
717
+ if (command === "bundle" && subcommand === "upload") {
718
+ const [appId, version, zipPath, ...rest] = args;
719
+ if (!appId || !version || !zipPath) {
720
+ throw new Error(
721
+ "Usage: beamdeploy bundle upload <appId> <version> <zipPath> [--channel production] [--platform electron] [--delta] [--delta-base <version>]"
722
+ );
723
+ }
724
+ const channel = argValue(rest, "--channel", "production");
725
+ const uploadPlatform = argValue(rest, "--platform", "").toLowerCase();
726
+ const deployKey = argValue(rest, "--deploy-key", "");
727
+ const useDelta = hasFlag(rest, "--delta");
728
+ const deltaBaseExplicit = argValue(rest, "--delta-base", "");
729
+ const fullBody = await (0, import_promises2.readFile)(zipPath);
730
+ let uploadBody = fullBody;
731
+ let uploadQuery = `channel=${encodeURIComponent(channel)}`;
732
+ if (uploadPlatform === "electron") {
733
+ uploadQuery += "&platform=electron&runtime=electron";
734
+ } else if (uploadPlatform) {
735
+ throw new Error('bundle upload --platform supports only "electron" (Capacitor/RN use default or rn upload).');
736
+ }
737
+ if (hasFlag(rest, "--preflight")) {
738
+ const context = await loadAuthContext(deployKey || void 0);
739
+ assertDeployKeyPreflight(context, { appId, channel, action: "upload" });
740
+ assertDeployKeyPreflight(context, { appId, channel, action: "promote" });
741
+ }
742
+ if (useDelta) {
743
+ const baseVersion = await resolveDeltaBaseVersion(appId, channel, deltaBaseExplicit, deployKey || void 0);
744
+ if (baseVersion === version) {
745
+ throw new Error("Delta base version must differ from the version being uploaded.");
746
+ }
747
+ const baseBody = await downloadBundleZip(appId, baseVersion, deployKey || void 0);
748
+ const { deltaZip, changedCount, deletedCount } = await buildDeltaZipFromBundles(baseBody, fullBody, baseVersion);
749
+ if (shouldPreferFullUpload(deltaZip.length, fullBody.length)) {
750
+ console.error(
751
+ `Delta (${deltaZip.length} bytes) is not smaller than full bundle (${fullBody.length} bytes); uploading full zip.`
752
+ );
753
+ } else {
754
+ uploadBody = deltaZip;
755
+ uploadQuery += "&delta=1";
756
+ console.error(
757
+ `Uploading delta against ${baseVersion}: ${changedCount} changed, ${deletedCount} deleted (${deltaZip.length} bytes vs ${fullBody.length} full).`
758
+ );
759
+ }
760
+ }
761
+ const payload = await request(
762
+ `/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(version)}?${uploadQuery}`,
763
+ {
764
+ method: "POST",
765
+ headers: { "content-type": "application/zip" },
766
+ body: uploadBody,
767
+ token: deployKey || void 0
768
+ }
769
+ );
770
+ console.log(JSON.stringify(payload, null, 2));
771
+ return;
772
+ }
773
+ if (command === "bundle" && subcommand === "patch") {
774
+ const [appId, version, ...rest] = args;
775
+ if (!appId || !version) {
776
+ throw new Error(
777
+ "Usage: beamdeploy bundle patch <appId> <version> [--display-name NAME] [--description TEXT] [--notes TEXT] [--min-native V] [--max-native V] [--breaking]"
778
+ );
779
+ }
780
+ const body = {};
781
+ const displayName = argValue(rest, "--display-name", "");
782
+ const description = argValue(rest, "--description", "");
783
+ const notes = argValue(rest, "--notes", "");
784
+ const minNative = argValue(rest, "--min-native", "");
785
+ const maxNative = argValue(rest, "--max-native", "");
786
+ if (displayName) body.displayName = displayName;
787
+ if (description) body.description = description;
788
+ if (notes) body.notes = notes;
789
+ if (minNative) body.minNativeVersion = minNative;
790
+ if (maxNative) body.maxNativeVersion = maxNative;
791
+ if (hasFlag(rest, "--breaking")) body.breaking = true;
792
+ const payload = await request(`/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(version)}`, {
793
+ method: "PATCH",
794
+ body: JSON.stringify(body)
795
+ });
796
+ console.log(JSON.stringify(payload.bundle, null, 2));
797
+ return;
798
+ }
799
+ if (command === "bundle" && subcommand === "trash") {
800
+ const [appId, version] = args;
801
+ if (!appId || !version) throw new Error("Usage: beamdeploy bundle trash <appId> <version>");
802
+ const payload = await request(`/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(version)}`, {
803
+ method: "DELETE"
804
+ });
805
+ console.log(JSON.stringify(payload.bundle, null, 2));
806
+ return;
807
+ }
808
+ if (command === "bundle" && subcommand === "restore") {
809
+ const [appId, version] = args;
810
+ if (!appId || !version) throw new Error("Usage: beamdeploy bundle restore <appId> <version>");
811
+ const payload = await request(
812
+ `/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(version)}/restore`,
813
+ { method: "POST" }
814
+ );
815
+ console.log(JSON.stringify(payload.bundle, null, 2));
816
+ return;
817
+ }
818
+ if (command === "channel" && subcommand === "trash") {
819
+ const [appId, channel] = args;
820
+ if (!appId || !channel) throw new Error("Usage: beamdeploy channel trash <appId> <channel>");
821
+ const payload = await request(`/v1/apps/${encodeURIComponent(appId)}/channels/${encodeURIComponent(channel)}`, {
822
+ method: "DELETE"
823
+ });
824
+ console.log(JSON.stringify(payload.channel, null, 2));
825
+ return;
826
+ }
827
+ if (command === "channel" && subcommand === "restore") {
828
+ const [appId, channel] = args;
829
+ if (!appId || !channel) throw new Error("Usage: beamdeploy channel restore <appId> <channel>");
830
+ const payload = await request(
831
+ `/v1/apps/${encodeURIComponent(appId)}/channels/${encodeURIComponent(channel)}/restore`,
832
+ { method: "POST" }
833
+ );
834
+ console.log(JSON.stringify(payload.channel, null, 2));
835
+ return;
836
+ }
837
+ if (command === "channel" && subcommand === "promote") {
838
+ const [appId, channel, version] = args;
839
+ if (!appId || !channel || !version) {
840
+ throw new Error("Usage: beamdeploy channel promote <appId> <channel> <version> [--preflight]");
841
+ }
842
+ const deployKey = argValue(args, "--deploy-key", "");
843
+ if (hasFlag(args, "--preflight")) {
844
+ const context = await loadAuthContext(deployKey || void 0);
845
+ assertDeployKeyPreflight(context, { appId, channel, action: "promote" });
846
+ }
847
+ const payload = await request(`/v1/apps/${encodeURIComponent(appId)}/channels/${encodeURIComponent(channel)}`, {
848
+ method: "PATCH",
849
+ body: JSON.stringify({ bundleVersion: version }),
850
+ token: deployKey || void 0
851
+ });
852
+ console.log(JSON.stringify(payload.channel, null, 2));
853
+ return;
854
+ }
855
+ if (command === "channel" && subcommand === "set") {
856
+ const [appId, channel, ...rest] = args;
857
+ const version = argValue(rest, "--bundle", "") || rest[0];
858
+ if (!appId || !channel || !version) {
859
+ throw new Error("Usage: beamdeploy channel set <appId> <channel> --bundle <version> [--preflight]");
860
+ }
861
+ const deployKey = argValue(rest, "--deploy-key", "");
862
+ if (hasFlag(rest, "--preflight")) {
863
+ const context = await loadAuthContext(deployKey || void 0);
864
+ assertDeployKeyPreflight(context, { appId, channel, action: "promote" });
865
+ }
866
+ const payload = await request(`/v1/apps/${encodeURIComponent(appId)}/channels/${encodeURIComponent(channel)}`, {
867
+ method: "PATCH",
868
+ body: JSON.stringify({ bundleVersion: version }),
869
+ token: deployKey || void 0
870
+ });
871
+ console.log(JSON.stringify(payload.channel, null, 2));
872
+ return;
873
+ }
874
+ if (command === "channel" && subcommand === "ensure") {
875
+ const [appId, channel, ...rest] = args;
876
+ if (!appId || !channel) {
877
+ throw new Error("Usage: beamdeploy channel ensure <appId> <channel> [--self-assign] [--preflight]");
878
+ }
879
+ const deployKey = argValue(rest, "--deploy-key", "");
880
+ const allowSelfAssign = hasFlag(rest, "--self-assign") || channel.startsWith("pr-");
881
+ if (hasFlag(rest, "--preflight")) {
882
+ const context = await loadAuthContext(deployKey || void 0);
883
+ assertDeployKeyPreflight(context, { appId, channel, action: "promote" });
884
+ }
885
+ const payload = await request(`/v1/apps/${encodeURIComponent(appId)}/channels`, {
886
+ method: "POST",
887
+ body: JSON.stringify({ name: channel, allowSelfAssign }),
888
+ token: deployKey || void 0
889
+ }).catch(async (error) => {
890
+ if (!String(error.message).startsWith("409:") && !String(error.message).includes("already")) {
891
+ throw error;
892
+ }
893
+ return request(`/v1/apps/${encodeURIComponent(appId)}/channels/${encodeURIComponent(channel)}`, {
894
+ method: "PATCH",
895
+ body: JSON.stringify({}),
896
+ token: deployKey || void 0
897
+ });
898
+ });
899
+ console.log(JSON.stringify(payload.channel, null, 2));
900
+ return;
901
+ }
902
+ if (command === "stats") {
903
+ const appId = subcommand;
904
+ if (!appId) throw new Error("Usage: beamdeploy stats <appId> [--preflight]");
905
+ const deployKey = argValue(args, "--deploy-key", "");
906
+ if (hasFlag(args, "--preflight")) {
907
+ const context = await loadAuthContext(deployKey || void 0);
908
+ assertDeployKeyPreflight(context, { appId, action: "stats" });
909
+ }
910
+ const payload = await request(`/v1/statistics/app/${encodeURIComponent(appId)}`, {
911
+ token: deployKey || void 0
912
+ });
913
+ console.log(JSON.stringify(payload.statistics, null, 2));
914
+ return;
915
+ }
916
+ usage();
917
+ process.exitCode = 1;
918
+ }
919
+ main().catch((error) => {
920
+ console.error(error.message);
921
+ process.exitCode = 1;
922
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@beamdeploy/cli",
3
+ "version": "0.2.0-preview.1",
4
+ "description": "BeamDeploy CLI — push OTA bundles and React Native updates from CI/CD pipelines",
5
+ "type": "module",
6
+ "bin": {
7
+ "beamdeploy": "./dist/cli.cjs"
8
+ },
9
+ "files": [
10
+ "dist/",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "esbuild src/cli.mjs --bundle --platform=node --target=node20 --format=cjs --banner:js='#!/usr/bin/env node' --external:yauzl --outfile=dist/cli.cjs",
15
+ "pack:check": "node ../../scripts/beamdeploy-cli-pack-check.mjs",
16
+ "prepack": "npm run build && npm run pack:check",
17
+ "prepublishOnly": "npm run build && npm run pack:check"
18
+ },
19
+ "dependencies": {
20
+ "yauzl": "^3.3.0"
21
+ },
22
+ "devDependencies": {
23
+ "esbuild": "^0.25.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=20.0.0"
27
+ },
28
+ "keywords": [
29
+ "beamdeploy",
30
+ "ota",
31
+ "live-update",
32
+ "react-native",
33
+ "capacitor",
34
+ "ci",
35
+ "cli"
36
+ ],
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/beamdeploy/beamdeploy.git",
41
+ "directory": "packages/beamdeploy-cli"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }