tidepush 0.1.0 → 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.
Files changed (2) hide show
  1. package/dist/index.js +130 -47
  2. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -58,7 +58,11 @@ async function api(cfg, path, init = {}) {
58
58
  headers["content-type"] = "application/json";
59
59
  body = JSON.stringify(init.json);
60
60
  }
61
- const res = await fetch(`${cfg.adminBase ?? `${cfg.server}/admin`}${path}`, { method: init.method ?? "GET", headers, body });
61
+ const res = await fetch(`${cfg.adminBase ?? `${cfg.server}/admin`}${path}`, {
62
+ method: init.method ?? "GET",
63
+ headers,
64
+ body
65
+ });
62
66
  const text = await res.text();
63
67
  if (!res.ok) {
64
68
  let message = text;
@@ -72,17 +76,26 @@ async function api(cfg, path, init = {}) {
72
76
 
73
77
  // src/commands.ts
74
78
  async function promote(cfg, id, channel, rolloutPct) {
75
- const res = await api(cfg, appPath(cfg, `/updates/${encodeURIComponent(id)}/promote`), { method: "POST", json: { channel, ...rolloutPct !== undefined ? { rolloutPct } : {} } });
79
+ const res = await api(cfg, appPath(cfg, `/updates/${encodeURIComponent(id)}/promote`), {
80
+ method: "POST",
81
+ json: { channel, ...rolloutPct !== undefined ? { rolloutPct } : {} }
82
+ });
76
83
  console.log(`✔ Promoted ${id} to ${channel} (group ${res.groupId})`);
77
84
  for (const u of res.updates)
78
85
  console.log(` ${u.platform.padEnd(8)} ${u.id}`);
79
86
  }
80
87
  async function rollout(cfg, id, pct) {
81
- const res = await api(cfg, appPath(cfg, `/updates/${encodeURIComponent(id)}/rollout`), { method: "POST", json: { pct } });
88
+ const res = await api(cfg, appPath(cfg, `/updates/${encodeURIComponent(id)}/rollout`), {
89
+ method: "POST",
90
+ json: { pct }
91
+ });
82
92
  console.log(`✔ Rollout set to ${pct}% for ${res.updates.map((u) => `${u.platform} ${u.id}`).join(", ")}`);
83
93
  }
84
94
  async function rollback(cfg, id, toEmbedded) {
85
- const res = await api(cfg, appPath(cfg, `/updates/${encodeURIComponent(id)}/rollback`), { method: "POST", json: { toEmbedded } });
95
+ const res = await api(cfg, appPath(cfg, `/updates/${encodeURIComponent(id)}/rollback`), {
96
+ method: "POST",
97
+ json: { toEmbedded }
98
+ });
86
99
  for (const r of res.results) {
87
100
  const what = r.outcome === "republished" ? `previous update republished as ${r.republishedId}` : r.outcome === "embedded" ? "devices will roll back to the embedded bundle" : "rolled back";
88
101
  console.log(`✔ ${r.platform.padEnd(8)} ${r.id}: ${what}`);
@@ -150,7 +163,8 @@ async function choose(question, items, label) {
150
163
  if (!process.stdin.isTTY) {
151
164
  throw new Error(`${question} There are several; pass it as a flag (see \`tidepush help\`).`);
152
165
  }
153
- items.forEach((t, i) => console.log(` ${i + 1}) ${label(t)}`));
166
+ for (const [i, t] of items.entries())
167
+ console.log(` ${i + 1}) ${label(t)}`);
154
168
  const rl = createInterface({ input: process.stdin, output: process.stdout });
155
169
  try {
156
170
  for (;; ) {
@@ -231,7 +245,10 @@ async function init(cfg, opts) {
231
245
  };
232
246
  if (!existsSync(appJsonPath)) {
233
247
  console.log("! No app.json here (app.config.ts?). Add these to your config's `expo` object:");
234
- console.log(JSON.stringify({ runtimeVersion: { policy: "fingerprint" }, updates: { ...updates, requestHeaders: { "expo-channel-name": opts.channel ?? "production" } } }, null, 2));
248
+ console.log(JSON.stringify({
249
+ runtimeVersion: { policy: "fingerprint" },
250
+ updates: { ...updates, requestHeaders: { "expo-channel-name": opts.channel ?? "production" } }
251
+ }, null, 2));
235
252
  return;
236
253
  }
237
254
  const raw = JSON.parse(await readFile2(appJsonPath, "utf8"));
@@ -276,7 +293,9 @@ function openBrowser(url) {
276
293
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
277
294
  async function login(opts = {}) {
278
295
  const web = (opts.webUrl ?? process.env.TIDEPUSH_WEB_URL ?? DEFAULT_WEB_URL).replace(/\/$/, "");
279
- const start = await post(`${web}/api/auth/device/code`, { client_id: CLIENT_ID });
296
+ const start = await post(`${web}/api/auth/device/code`, {
297
+ client_id: CLIENT_ID
298
+ });
280
299
  if (start.status !== 200 || !start.data.device_code) {
281
300
  throw new Error(`Couldn't start signing in at ${web} (${start.status}). ${start.data.error_description ?? ""}`.trim());
282
301
  }
@@ -340,9 +359,12 @@ async function logout() {
340
359
 
341
360
  // src/publish.ts
342
361
  import { spawn as spawn2 } from "node:child_process";
343
- import { mkdtemp, readFile as readFile4, rm as rm2 } from "node:fs/promises";
362
+ import { constants } from "node:fs";
363
+ import { access, mkdtemp, readFile as readFile4, rm as rm2 } from "node:fs/promises";
344
364
  import { tmpdir } from "node:os";
345
- import { join as join4, resolve as resolve2 } from "node:path";
365
+ import { dirname as dirname2, join as join4, resolve as resolve2 } from "node:path";
366
+ import { promisify } from "node:util";
367
+ import { gzip } from "node:zlib";
346
368
 
347
369
  // src/export.ts
348
370
  import { createHash } from "node:crypto";
@@ -369,30 +391,37 @@ var CONTENT_TYPES = {
369
391
  function contentTypeFor(ext) {
370
392
  return CONTENT_TYPES[ext.toLowerCase()] ?? "application/octet-stream";
371
393
  }
372
- async function hashFile(path) {
373
- const bytes = await readFile3(path);
374
- return { key: createHash("sha256").update(bytes).digest("hex"), size: bytes.byteLength };
375
- }
376
394
  async function readExport(distDir, platforms) {
377
395
  const metadata = JSON.parse(await readFile3(join3(distDir, "metadata.json"), "utf8"));
378
- const bundles = [];
379
- for (const platform of platforms) {
396
+ const hashes = new Map;
397
+ const hash = (path) => {
398
+ let h = hashes.get(path);
399
+ if (!h) {
400
+ h = readFile3(path).then((bytes) => ({
401
+ key: createHash("sha256").update(bytes).digest("hex"),
402
+ size: bytes.byteLength
403
+ }));
404
+ hashes.set(path, h);
405
+ }
406
+ return h;
407
+ };
408
+ return Promise.all(platforms.map(async (platform) => {
380
409
  const meta = metadata.fileMetadata[platform];
381
410
  if (!meta)
382
411
  throw new Error(`metadata.json has no ${platform} entry; was it exported?`);
383
412
  const bundlePath = join3(distDir, meta.bundle);
384
- const launch = await hashFile(bundlePath);
413
+ const files = meta.assets.map((a) => ({ path: join3(distDir, a.path), ext: a.ext }));
414
+ const [launch, ...hashed] = await Promise.all([hash(bundlePath), ...files.map((f) => hash(f.path))]);
385
415
  const assets = [];
386
416
  const seen = new Set([launch.key]);
387
- for (const a of meta.assets) {
388
- const path = join3(distDir, a.path);
389
- const { key, size } = await hashFile(path);
417
+ files.forEach(({ path, ext }, i) => {
418
+ const { key, size } = hashed[i];
390
419
  if (seen.has(key))
391
- continue;
420
+ return;
392
421
  seen.add(key);
393
- assets.push({ path, key, size, contentType: contentTypeFor(a.ext), fileExtension: `.${a.ext}` });
394
- }
395
- bundles.push({
422
+ assets.push({ path, key, size, contentType: contentTypeFor(ext), fileExtension: `.${ext}` });
423
+ });
424
+ return {
396
425
  platform,
397
426
  launchAsset: {
398
427
  path: bundlePath,
@@ -401,9 +430,8 @@ async function readExport(distDir, platforms) {
401
430
  fileExtension: ".bundle"
402
431
  },
403
432
  assets
404
- });
405
- }
406
- return bundles;
433
+ };
434
+ }));
407
435
  }
408
436
 
409
437
  // src/publish.ts
@@ -441,10 +469,47 @@ async function git(appDir, args) {
441
469
  return;
442
470
  }
443
471
  }
472
+ async function localCommand(appDir, pkg, args) {
473
+ if (process.platform !== "win32") {
474
+ for (let dir = appDir;; dir = dirname2(dir)) {
475
+ const bin = join4(dir, "node_modules", ".bin", pkg);
476
+ if (await access(bin, constants.X_OK).then(() => true, () => false))
477
+ return [bin, args];
478
+ if (dirname2(dir) === dir)
479
+ break;
480
+ }
481
+ }
482
+ return ["npx", [pkg, ...args]];
483
+ }
484
+ function literalRuntimeVersion(expoClient, platform) {
485
+ const platformValue = expoClient[platform]?.runtimeVersion;
486
+ const value = platformValue ?? expoClient.runtimeVersion;
487
+ return typeof value === "string" && value ? value : undefined;
488
+ }
489
+ var gzipAsync = promisify(gzip);
490
+ var UPLOAD_CONCURRENCY = 12;
491
+ var GZIP_MIN_BYTES = 1024;
492
+ async function uploadBody(bytes, canGzip) {
493
+ if (canGzip && bytes.byteLength >= GZIP_MIN_BYTES) {
494
+ const compressed = await gzipAsync(bytes, { level: 6 });
495
+ if (compressed.byteLength < bytes.byteLength * 0.9) {
496
+ return {
497
+ body: compressed,
498
+ headers: { "x-upload-encoding": "gzip", "x-decoded-length": String(bytes.byteLength) }
499
+ };
500
+ }
501
+ }
502
+ return { body: bytes, headers: {} };
503
+ }
444
504
  async function publish(cfg, opts) {
445
505
  const appDir = resolve2(opts.appDir);
506
+ const started = performance.now();
507
+ const elapsed = () => `${((performance.now() - started) / 1000).toFixed(1)}s`;
446
508
  const step = (msg) => console.log(`• ${msg}`);
447
- const runIn = (cmd, args, quiet = true) => run(cmd, args, { cwd: appDir, channel: opts.channel, quiet });
509
+ const runIn = async (pkg2, args, quiet = true) => {
510
+ const [cmd, argv] = await localCommand(appDir, pkg2, args);
511
+ return run(cmd, argv, { cwd: appDir, channel: opts.channel, quiet });
512
+ };
448
513
  const pkg = JSON.parse(await readFile4(join4(appDir, "package.json"), "utf8"));
449
514
  if (!pkg.dependencies?.["expo-updates"])
450
515
  throw new Error(`${appDir} does not depend on expo-updates`);
@@ -453,12 +518,26 @@ async function publish(cfg, opts) {
453
518
  if (!distDir) {
454
519
  tempDir = await mkdtemp(join4(tmpdir(), "tidepush-export-"));
455
520
  distDir = tempDir;
456
- step(`Exporting ${opts.platforms.join(" + ")} bundle(s)…`);
457
- await runIn("npx", ["expo", "export", ...opts.platforms.flatMap((p) => ["--platform", p]), "--output-dir", distDir], false);
458
521
  }
459
522
  try {
460
- step("Reading app config and runtime version…");
461
- const expoClient = lastJson(await runIn("npx", ["expo", "config", "--type", "public", "--json"]));
523
+ step(opts.distDir ? "Reading app config and runtime version…" : `Exporting ${opts.platforms.join(" + ")} bundle(s) and reading the app config…`);
524
+ const exported = opts.distDir ? Promise.resolve() : runIn("expo", ["export", ...opts.platforms.flatMap((p) => ["--platform", p]), "--output-dir", distDir], false);
525
+ const config = runIn("expo", ["config", "--type", "public", "--json"]).then((out) => lastJson(out));
526
+ const runtimes = config.then(async (expoClient2) => new Map(await Promise.all(opts.platforms.map(async (platform) => {
527
+ const rv = opts.runtimeVersion ?? literalRuntimeVersion(expoClient2, platform) ?? lastJson(await runIn("expo-updates", ["runtimeversion:resolve", "--platform", platform])).runtimeVersion;
528
+ if (!rv)
529
+ throw new Error(`Could not resolve a runtime version for ${platform}`);
530
+ return [platform, rv];
531
+ }))));
532
+ const gitInfo = Promise.all([git(appDir, ["rev-parse", "HEAD"]), git(appDir, ["status", "--porcelain"])]);
533
+ const [, expoClient, runtimeVersions, [sha, status]] = await settleAll([
534
+ exported,
535
+ config,
536
+ runtimes,
537
+ gitInfo
538
+ ]);
539
+ if (!opts.distDir)
540
+ step(`Exported in ${elapsed()}`);
462
541
  if (cfg && !cfg.appId) {
463
542
  const fromConfig = appIdFromUpdatesUrl(expoClient.updates?.url);
464
543
  if (fromConfig)
@@ -466,15 +545,7 @@ async function publish(cfg, opts) {
466
545
  }
467
546
  if (cfg?.appId)
468
547
  console.log(` app ${cfg.appId}`);
469
- const runtimeVersions = new Map;
470
- for (const platform of opts.platforms) {
471
- const rv = opts.runtimeVersion ?? lastJson(await runIn("npx", ["expo-updates", "runtimeversion:resolve", "--platform", platform])).runtimeVersion;
472
- if (!rv)
473
- throw new Error(`Could not resolve a runtime version for ${platform}`);
474
- runtimeVersions.set(platform, rv);
475
- }
476
- const sha = await git(appDir, ["rev-parse", "HEAD"]);
477
- const dirty = !!await git(appDir, ["status", "--porcelain"]);
548
+ const dirty = !!status;
478
549
  const gitSha = sha ? `${sha.slice(0, 12)}${dirty ? "-dirty" : ""}` : undefined;
479
550
  if (dirty)
480
551
  console.warn("! Working tree has uncommitted changes; they are included in this update.");
@@ -488,23 +559,28 @@ async function publish(cfg, opts) {
488
559
  console.log(` ${b.platform.padEnd(8)} runtime ${runtimeVersions.get(b.platform)} bundle ${kb(b.launchAsset.size)} ${b.assets.length} assets`);
489
560
  }
490
561
  if (!cfg) {
491
- console.log(`✔ Dry run: ${allAssets.size} unique files, ${kb(totalBytes)}. Nothing uploaded.`);
562
+ console.log(`✔ Dry run: ${allAssets.size} unique files, ${kb(totalBytes)}. Nothing uploaded. (${elapsed()})`);
492
563
  return;
493
564
  }
494
- const { missing } = await api(cfg, appPath(cfg, "/assets/missing"), {
565
+ const { missing, uploadEncodings } = await api(cfg, appPath(cfg, "/assets/missing"), {
495
566
  method: "POST",
496
567
  json: { keys: [...allAssets.keys()] }
497
568
  });
569
+ const canGzip = uploadEncodings?.includes("gzip") ?? false;
498
570
  const toUpload = missing.map((k) => allAssets.get(k));
499
571
  step(`Uploading ${toUpload.length}/${allAssets.size} files (${kb(toUpload.reduce((n, a) => n + a.size, 0))})…`);
500
- await pool(toUpload, 6, async (a) => {
501
- const body = await readFile4(a.path);
572
+ let sentBytes = 0;
573
+ await pool(toUpload, UPLOAD_CONCURRENCY, async (a) => {
574
+ const { body, headers } = await uploadBody(await readFile4(a.path), canGzip);
575
+ sentBytes += body.byteLength;
502
576
  await retry(() => api(cfg, appPath(cfg, `/assets/${a.key}`), {
503
577
  method: "PUT",
504
578
  body,
505
- headers: { "content-type": a.contentType, "x-file-extension": a.fileExtension }
579
+ headers: { "content-type": a.contentType, "x-file-extension": a.fileExtension, ...headers }
506
580
  }));
507
581
  });
582
+ if (toUpload.length)
583
+ step(`Uploaded ${kb(sentBytes)}${canGzip ? " (gzipped where it helps)" : ""} by ${elapsed()}`);
508
584
  const res = await api(cfg, appPath(cfg, "/updates"), {
509
585
  method: "POST",
510
586
  json: {
@@ -521,7 +597,7 @@ async function publish(cfg, opts) {
521
597
  }))
522
598
  }
523
599
  });
524
- console.log(`✔ Published to ${opts.channel} at ${opts.rolloutPct}% (group ${res.groupId})`);
600
+ console.log(`✔ Published to ${opts.channel} at ${opts.rolloutPct}% (group ${res.groupId}) in ${elapsed()}`);
525
601
  for (const u of res.updates)
526
602
  console.log(` ${u.platform.padEnd(8)} ${u.id}`);
527
603
  } finally {
@@ -541,6 +617,13 @@ async function retry(fn, attempts = 4, baseDelayMs = 1000) {
541
617
  }
542
618
  }
543
619
  }
620
+ async function settleAll(promises) {
621
+ const settled = await Promise.allSettled(promises);
622
+ const failed = settled.find((r) => r.status === "rejected");
623
+ if (failed)
624
+ throw failed.reason;
625
+ return settled.map((r) => r.value);
626
+ }
544
627
  async function pool(items, size, fn) {
545
628
  const queue = [...items];
546
629
  await Promise.all(Array.from({ length: Math.min(size, queue.length) }, async () => {
@@ -590,7 +673,7 @@ if (!("Bun" in globalThis)) {
590
673
  async function main(argv) {
591
674
  const [command, ...rest] = argv;
592
675
  if (command === "--version" || command === "-v" || command === "version") {
593
- console.log("0.1.0");
676
+ console.log("0.2.0");
594
677
  return;
595
678
  }
596
679
  if (!command || command === "help" || command === "--help" || command === "-h") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tidepush",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Publish and manage OTA updates for Expo apps with Tidepush.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,9 @@
33
33
  },
34
34
  "license": "AGPL-3.0-only",
35
35
  "devDependencies": {
36
+ "@tidepush/api": "workspace:*",
37
+ "@tidepush/tsconfig": "workspace:*",
36
38
  "@types/bun": "^1.4.3",
37
- "typescript": "^7.0.2"
39
+ "typescript": "catalog:"
38
40
  }
39
41
  }