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.
- package/dist/index.js +130 -47
- 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}`, {
|
|
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`), {
|
|
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`), {
|
|
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`), {
|
|
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
|
-
|
|
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({
|
|
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`, {
|
|
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 {
|
|
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
|
|
379
|
-
|
|
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
|
|
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
|
-
|
|
388
|
-
const
|
|
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
|
-
|
|
420
|
+
return;
|
|
392
421
|
seen.add(key);
|
|
393
|
-
assets.push({ path, key, size, contentType: contentTypeFor(
|
|
394
|
-
}
|
|
395
|
-
|
|
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 = (
|
|
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
|
|
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
|
|
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
|
-
|
|
501
|
-
|
|
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.
|
|
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.
|
|
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": "
|
|
39
|
+
"typescript": "catalog:"
|
|
38
40
|
}
|
|
39
41
|
}
|