tidepush 0.0.0-stage → 0.1.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 (4) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +16 -2
  3. package/dist/index.js +698 -0
  4. package/package.json +37 -4
package/dist/index.js ADDED
@@ -0,0 +1,698 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { readFileSync } from "node:fs";
5
+ import { join as join5 } from "node:path";
6
+ import { parseArgs } from "node:util";
7
+
8
+ // src/api.ts
9
+ class ApiError extends Error {
10
+ status;
11
+ constructor(status, message) {
12
+ super(message);
13
+ this.status = status;
14
+ }
15
+ }
16
+ var APP_ID = /^app_[a-z0-9_]{1,40}$/;
17
+ function appIdFromUpdatesUrl(url) {
18
+ if (typeof url !== "string")
19
+ return;
20
+ try {
21
+ return new URL(url).pathname.match(/^\/(app_[a-z0-9_]{1,40})\/manifest\/?$/)?.[1];
22
+ } catch {
23
+ return;
24
+ }
25
+ }
26
+ var appPath = (cfg, path) => cfg.appId ? `/apps/${encodeURIComponent(cfg.appId)}${path}` : path;
27
+ var DEFAULT_SERVER_URL = "https://tidepush.yugenecodes99.workers.dev";
28
+ var DEFAULT_WEB_URL = "https://tidepush-web.yugenecodes99.workers.dev";
29
+ function apiConfigFromEnv(overrides = {}, env = process.env, credentials = null) {
30
+ const token = env.TIDEPUSH_TOKEN ?? env.TIDEPUSH_API_TOKEN;
31
+ const appId = overrides.app ?? env.TIDEPUSH_APP_ID;
32
+ if (appId && !APP_ID.test(appId))
33
+ throw new Error(`"${appId}" is not an app id (they look like app_xxxxxxxx)`);
34
+ const app = appId ? { appId } : {};
35
+ if (!token && credentials) {
36
+ const web = credentials.webUrl.replace(/\/$/, "");
37
+ return { server: web, token: credentials.token, adminBase: `${web}/api/v1`, ...app };
38
+ }
39
+ if (!token)
40
+ throw new Error("Not logged in. Run `npx tidepush login`, or set TIDEPUSH_TOKEN to an API token.");
41
+ const server = overrides.server ?? env.TIDEPUSH_SERVER_URL ?? DEFAULT_SERVER_URL;
42
+ return {
43
+ server: server.replace(/\/$/, ""),
44
+ token,
45
+ ...app,
46
+ accessClientId: env.TIDEPUSH_ACCESS_CLIENT_ID,
47
+ accessClientSecret: env.TIDEPUSH_ACCESS_CLIENT_SECRET
48
+ };
49
+ }
50
+ async function api(cfg, path, init = {}) {
51
+ const headers = { authorization: `Bearer ${cfg.token}`, ...init.headers };
52
+ if (cfg.accessClientId && cfg.accessClientSecret) {
53
+ headers["cf-access-client-id"] = cfg.accessClientId;
54
+ headers["cf-access-client-secret"] = cfg.accessClientSecret;
55
+ }
56
+ let body = init.body;
57
+ if (init.json !== undefined) {
58
+ headers["content-type"] = "application/json";
59
+ body = JSON.stringify(init.json);
60
+ }
61
+ const res = await fetch(`${cfg.adminBase ?? `${cfg.server}/admin`}${path}`, { method: init.method ?? "GET", headers, body });
62
+ const text = await res.text();
63
+ if (!res.ok) {
64
+ let message = text;
65
+ try {
66
+ message = JSON.parse(text).error ?? text;
67
+ } catch {}
68
+ throw new ApiError(res.status, `${init.method ?? "GET"} ${path} → ${res.status}: ${message}`);
69
+ }
70
+ return text ? JSON.parse(text) : undefined;
71
+ }
72
+
73
+ // src/commands.ts
74
+ 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 } : {} } });
76
+ console.log(`✔ Promoted ${id} to ${channel} (group ${res.groupId})`);
77
+ for (const u of res.updates)
78
+ console.log(` ${u.platform.padEnd(8)} ${u.id}`);
79
+ }
80
+ async function rollout(cfg, id, pct) {
81
+ const res = await api(cfg, appPath(cfg, `/updates/${encodeURIComponent(id)}/rollout`), { method: "POST", json: { pct } });
82
+ console.log(`✔ Rollout set to ${pct}% for ${res.updates.map((u) => `${u.platform} ${u.id}`).join(", ")}`);
83
+ }
84
+ async function rollback(cfg, id, toEmbedded) {
85
+ const res = await api(cfg, appPath(cfg, `/updates/${encodeURIComponent(id)}/rollback`), { method: "POST", json: { toEmbedded } });
86
+ for (const r of res.results) {
87
+ 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
+ console.log(`✔ ${r.platform.padEnd(8)} ${r.id}: ${what}`);
89
+ }
90
+ }
91
+ async function list(cfg, q) {
92
+ const params = new URLSearchParams;
93
+ for (const [k, v] of Object.entries(q))
94
+ if (typeof v === "string" && v)
95
+ params.set(k, v);
96
+ const { updates } = await api(cfg, appPath(cfg, `/updates?${params}`));
97
+ if (q.json) {
98
+ console.log(JSON.stringify(updates, null, 2));
99
+ return;
100
+ }
101
+ if (!updates.length) {
102
+ console.log("No updates.");
103
+ return;
104
+ }
105
+ console.table(updates.map((u) => ({
106
+ created: u.createdAt.replace("T", " ").slice(0, 16),
107
+ channel: u.channel,
108
+ platform: u.platform,
109
+ runtime: u.runtimeVersion.slice(0, 10),
110
+ id: u.id,
111
+ group: u.groupId.slice(0, 8),
112
+ rollout: `${u.rolloutPct}%`,
113
+ status: u.status,
114
+ devices: u.deviceCount ?? 0,
115
+ message: (u.message ?? "").slice(0, 40)
116
+ })));
117
+ }
118
+
119
+ // src/credentials.ts
120
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
121
+ import { homedir } from "node:os";
122
+ import { dirname, join } from "node:path";
123
+ var credentialsPath = (env = process.env) => env.TIDEPUSH_CREDENTIALS ?? join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "tidepush", "credentials.json");
124
+ async function readCredentials(path = credentialsPath()) {
125
+ try {
126
+ const c = JSON.parse(await readFile(path, "utf8"));
127
+ return c.webUrl && c.token && c.email ? { webUrl: c.webUrl, token: c.token, email: c.email } : null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+ async function writeCredentials(c, path = credentialsPath()) {
133
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
134
+ await writeFile(path, `${JSON.stringify(c, null, 2)}
135
+ `, { mode: 384 });
136
+ await chmod(path, 384);
137
+ }
138
+ async function deleteCredentials(path = credentialsPath()) {
139
+ await rm(path, { force: true });
140
+ }
141
+
142
+ // src/init.ts
143
+ import { existsSync } from "node:fs";
144
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
145
+ import { join as join2, resolve } from "node:path";
146
+ import { createInterface } from "node:readline/promises";
147
+ async function choose(question, items, label) {
148
+ if (items.length === 1)
149
+ return items[0];
150
+ if (!process.stdin.isTTY) {
151
+ throw new Error(`${question} There are several; pass it as a flag (see \`tidepush help\`).`);
152
+ }
153
+ items.forEach((t, i) => console.log(` ${i + 1}) ${label(t)}`));
154
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
155
+ try {
156
+ for (;; ) {
157
+ const answer = Number((await rl.question(`${question} [1-${items.length}] `)).trim());
158
+ if (Number.isInteger(answer) && answer >= 1 && answer <= items.length)
159
+ return items[answer - 1];
160
+ }
161
+ } finally {
162
+ rl.close();
163
+ }
164
+ }
165
+ async function ask(question) {
166
+ if (!process.stdin.isTTY)
167
+ throw new Error(`${question} Pass --create-app <name>.`);
168
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
169
+ try {
170
+ return (await rl.question(`${question} `)).trim();
171
+ } finally {
172
+ rl.close();
173
+ }
174
+ }
175
+ async function resolveApp(cfg, opts) {
176
+ if (opts.appId)
177
+ return api(cfg, `/apps/${encodeURIComponent(opts.appId)}`);
178
+ let orgId = opts.orgId;
179
+ if (!orgId) {
180
+ const me = await api(cfg, "/me");
181
+ if (me.orgs) {
182
+ if (!me.orgs.length)
183
+ throw new Error("You're not in an organization yet. Create one in the dashboard first.");
184
+ orgId = (await choose("Which organization?", me.orgs, (o) => `${o.name} (${o.role})`)).id;
185
+ }
186
+ }
187
+ const base = orgId ? `/orgs/${encodeURIComponent(orgId)}` : "";
188
+ if (opts.createApp)
189
+ return createApp(cfg, base, opts.createApp);
190
+ const { apps } = await api(cfg, `${base}/apps`);
191
+ const active = apps.filter((a) => !a.archived);
192
+ const NEW = { id: "", name: "Create a new app" };
193
+ const picked = await choose("Which app?", active.length ? [...active, NEW] : [NEW], (a) => a.id ? `${a.name} (${a.id})` : a.name);
194
+ return picked.id ? picked : createApp(cfg, base, await ask("Name of the new app?"));
195
+ }
196
+ async function createApp(cfg, base, name) {
197
+ if (!name)
198
+ throw new Error("The app needs a name.");
199
+ const app = await api(cfg, `${base}/apps`, { method: "POST", json: { name } });
200
+ console.log(`✔ Created app "${app.name}" (${app.id})`);
201
+ return app;
202
+ }
203
+ async function updatesBase(cfg) {
204
+ if (cfg.adminBase) {
205
+ const res = await fetch(`${cfg.server}/api/config`);
206
+ if (res.ok)
207
+ return (await res.json()).updatesUrl.replace(/\/$/, "");
208
+ }
209
+ return cfg.server;
210
+ }
211
+ async function init(cfg, opts) {
212
+ const appDir = resolve(opts.appDir);
213
+ if (!existsSync(join2(appDir, "package.json")))
214
+ throw new Error(`${appDir} has no package.json. Run this in your Expo app.`);
215
+ const app = await resolveApp(cfg, opts);
216
+ const scope = { ...cfg, appId: app.id };
217
+ const { keys } = await api(scope, `/apps/${app.id}/keys`);
218
+ const key = keys.find((k) => !k.retired && k.keyId === "main") ?? keys.find((k) => !k.retired);
219
+ if (!key) {
220
+ throw new Error(`${app.name} has no signing key with a certificate to download. Add one in the dashboard (App settings → Code signing).`);
221
+ }
222
+ await mkdir2(join2(appDir, "certs"), { recursive: true });
223
+ await writeFile2(join2(appDir, "certs", "certificate.pem"), key.certificatePem);
224
+ console.log("✔ Saved certs/certificate.pem");
225
+ const url = `${await updatesBase(cfg)}/${app.id}/manifest`;
226
+ const appJsonPath = join2(appDir, "app.json");
227
+ const updates = {
228
+ url,
229
+ codeSigningCertificate: "./certs/certificate.pem",
230
+ codeSigningMetadata: { keyid: key.keyId, alg: "rsa-v1_5-sha256" }
231
+ };
232
+ if (!existsSync(appJsonPath)) {
233
+ 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));
235
+ return;
236
+ }
237
+ const raw = JSON.parse(await readFile2(appJsonPath, "utf8"));
238
+ const expo = raw.expo ?? raw;
239
+ const current = expo.updates ?? {};
240
+ const headers = current.requestHeaders ?? {};
241
+ expo.updates = {
242
+ enabled: true,
243
+ checkAutomatically: "ON_LOAD",
244
+ fallbackToCacheTimeout: 0,
245
+ ...current,
246
+ ...updates,
247
+ requestHeaders: { ...headers, "expo-channel-name": headers["expo-channel-name"] ?? opts.channel ?? "production" }
248
+ };
249
+ expo.runtimeVersion ??= { policy: "fingerprint" };
250
+ await writeFile2(appJsonPath, `${JSON.stringify(raw, null, 2)}
251
+ `);
252
+ console.log("✔ Updated app.json");
253
+ console.log(` updates.url ${url}`);
254
+ console.log("Next: make a release build, then `npx tidepush publish --channel production` after it.");
255
+ }
256
+
257
+ // src/login.ts
258
+ import { spawn } from "node:child_process";
259
+ var CLIENT_ID = "tidepush-cli";
260
+ var GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
261
+ async function post(url, body, headers = {}) {
262
+ const res = await fetch(url, {
263
+ method: "POST",
264
+ headers: { "content-type": "application/json", ...headers },
265
+ body: JSON.stringify(body)
266
+ });
267
+ return { status: res.status, data: await res.json().catch(() => ({})) };
268
+ }
269
+ function openBrowser(url) {
270
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
271
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
272
+ try {
273
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref();
274
+ } catch {}
275
+ }
276
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
277
+ async function login(opts = {}) {
278
+ 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 });
280
+ if (start.status !== 200 || !start.data.device_code) {
281
+ throw new Error(`Couldn't start signing in at ${web} (${start.status}). ${start.data.error_description ?? ""}`.trim());
282
+ }
283
+ const code = start.data;
284
+ const absolute = (u) => new URL(u, web).toString();
285
+ const link = absolute(code.verification_uri_complete ?? `${code.verification_uri}?user_code=${code.user_code}`);
286
+ console.log(`Your code: ${code.user_code}`);
287
+ console.log(`Approve it at ${link}`);
288
+ if (opts.open !== false)
289
+ openBrowser(link);
290
+ let interval = Math.max(1, code.interval) * 1000;
291
+ const deadline = Date.now() + code.expires_in * 1000;
292
+ while (Date.now() < deadline) {
293
+ await sleep(interval);
294
+ const res = await post(`${web}/api/auth/device/token`, {
295
+ grant_type: GRANT_TYPE,
296
+ device_code: code.device_code,
297
+ client_id: CLIENT_ID
298
+ });
299
+ if (res.status === 429) {
300
+ interval += 5000;
301
+ continue;
302
+ }
303
+ if (res.data.access_token) {
304
+ const session = await fetch(`${web}/api/auth/get-session`, {
305
+ headers: { authorization: `Bearer ${res.data.access_token}` }
306
+ }).then((r) => r.json());
307
+ const email = session?.user?.email ?? "unknown";
308
+ await writeCredentials({ webUrl: web, token: res.data.access_token, email });
309
+ console.log(`✔ Logged in as ${email}`);
310
+ return;
311
+ }
312
+ switch (res.data.error) {
313
+ case "authorization_pending":
314
+ break;
315
+ case "slow_down":
316
+ interval += 5000;
317
+ break;
318
+ case "access_denied":
319
+ throw new Error("The request was denied in the browser.");
320
+ case "expired_token":
321
+ throw new Error("The code expired. Run `npx tidepush login` again.");
322
+ default:
323
+ throw new Error(`Signing in failed: ${res.data.error_description ?? res.data.error ?? res.status}`);
324
+ }
325
+ }
326
+ throw new Error("The code expired. Run `npx tidepush login` again.");
327
+ }
328
+ async function logout() {
329
+ const saved = await readCredentials();
330
+ if (!saved) {
331
+ console.log("Not logged in.");
332
+ return;
333
+ }
334
+ await post(`${saved.webUrl}/api/auth/sign-out`, {}, { authorization: `Bearer ${saved.token}` }).catch(() => {
335
+ return;
336
+ });
337
+ await deleteCredentials();
338
+ console.log(`✔ Logged out ${saved.email}`);
339
+ }
340
+
341
+ // src/publish.ts
342
+ import { spawn as spawn2 } from "node:child_process";
343
+ import { mkdtemp, readFile as readFile4, rm as rm2 } from "node:fs/promises";
344
+ import { tmpdir } from "node:os";
345
+ import { join as join4, resolve as resolve2 } from "node:path";
346
+
347
+ // src/export.ts
348
+ import { createHash } from "node:crypto";
349
+ import { readFile as readFile3 } from "node:fs/promises";
350
+ import { join as join3 } from "node:path";
351
+ var CONTENT_TYPES = {
352
+ png: "image/png",
353
+ jpg: "image/jpeg",
354
+ jpeg: "image/jpeg",
355
+ gif: "image/gif",
356
+ webp: "image/webp",
357
+ bmp: "image/bmp",
358
+ svg: "image/svg+xml",
359
+ ttf: "font/ttf",
360
+ otf: "font/otf",
361
+ woff: "font/woff",
362
+ woff2: "font/woff2",
363
+ json: "application/json",
364
+ mp3: "audio/mpeg",
365
+ wav: "audio/wav",
366
+ mp4: "video/mp4",
367
+ lottie: "application/json"
368
+ };
369
+ function contentTypeFor(ext) {
370
+ return CONTENT_TYPES[ext.toLowerCase()] ?? "application/octet-stream";
371
+ }
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
+ async function readExport(distDir, platforms) {
377
+ const metadata = JSON.parse(await readFile3(join3(distDir, "metadata.json"), "utf8"));
378
+ const bundles = [];
379
+ for (const platform of platforms) {
380
+ const meta = metadata.fileMetadata[platform];
381
+ if (!meta)
382
+ throw new Error(`metadata.json has no ${platform} entry; was it exported?`);
383
+ const bundlePath = join3(distDir, meta.bundle);
384
+ const launch = await hashFile(bundlePath);
385
+ const assets = [];
386
+ 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);
390
+ if (seen.has(key))
391
+ continue;
392
+ seen.add(key);
393
+ assets.push({ path, key, size, contentType: contentTypeFor(a.ext), fileExtension: `.${a.ext}` });
394
+ }
395
+ bundles.push({
396
+ platform,
397
+ launchAsset: {
398
+ path: bundlePath,
399
+ ...launch,
400
+ contentType: "application/javascript",
401
+ fileExtension: ".bundle"
402
+ },
403
+ assets
404
+ });
405
+ }
406
+ return bundles;
407
+ }
408
+
409
+ // src/publish.ts
410
+ var childEnv = (channel, base = process.env) => ({
411
+ ...base,
412
+ TIDEPUSH_CHANNEL: channel,
413
+ CI: "1"
414
+ });
415
+ function run(cmd, args, opts) {
416
+ return new Promise((resolvePromise, reject) => {
417
+ const child = spawn2(cmd, args, {
418
+ cwd: opts.cwd,
419
+ env: childEnv(opts.channel),
420
+ stdio: ["ignore", "pipe", opts.quiet ? "pipe" : "inherit"]
421
+ });
422
+ let out = "";
423
+ let err = "";
424
+ child.stdout.on("data", (d) => out += d);
425
+ child.stderr?.on("data", (d) => err += d);
426
+ child.on("error", reject);
427
+ child.on("close", (code) => code === 0 ? resolvePromise(out) : reject(new Error(`${cmd} ${args.join(" ")} exited ${code}
428
+ ${err}`)));
429
+ });
430
+ }
431
+ function lastJson(stdout) {
432
+ const start = stdout.indexOf("{");
433
+ if (start < 0)
434
+ throw new Error(`Expected JSON, got: ${stdout.slice(0, 200)}`);
435
+ return JSON.parse(stdout.slice(start));
436
+ }
437
+ async function git(appDir, args) {
438
+ try {
439
+ return (await run("git", args, { cwd: appDir, channel: "", quiet: true })).trim();
440
+ } catch {
441
+ return;
442
+ }
443
+ }
444
+ async function publish(cfg, opts) {
445
+ const appDir = resolve2(opts.appDir);
446
+ const step = (msg) => console.log(`• ${msg}`);
447
+ const runIn = (cmd, args, quiet = true) => run(cmd, args, { cwd: appDir, channel: opts.channel, quiet });
448
+ const pkg = JSON.parse(await readFile4(join4(appDir, "package.json"), "utf8"));
449
+ if (!pkg.dependencies?.["expo-updates"])
450
+ throw new Error(`${appDir} does not depend on expo-updates`);
451
+ let distDir = opts.distDir ? resolve2(opts.distDir) : undefined;
452
+ let tempDir;
453
+ if (!distDir) {
454
+ tempDir = await mkdtemp(join4(tmpdir(), "tidepush-export-"));
455
+ 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
+ }
459
+ try {
460
+ step("Reading app config and runtime version…");
461
+ const expoClient = lastJson(await runIn("npx", ["expo", "config", "--type", "public", "--json"]));
462
+ if (cfg && !cfg.appId) {
463
+ const fromConfig = appIdFromUpdatesUrl(expoClient.updates?.url);
464
+ if (fromConfig)
465
+ cfg = { ...cfg, appId: fromConfig };
466
+ }
467
+ if (cfg?.appId)
468
+ 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"]);
478
+ const gitSha = sha ? `${sha.slice(0, 12)}${dirty ? "-dirty" : ""}` : undefined;
479
+ if (dirty)
480
+ console.warn("! Working tree has uncommitted changes; they are included in this update.");
481
+ const bundles = await readExport(distDir, opts.platforms);
482
+ const allAssets = new Map;
483
+ for (const b of bundles)
484
+ for (const a of [b.launchAsset, ...b.assets])
485
+ allAssets.set(a.key, a);
486
+ const totalBytes = [...allAssets.values()].reduce((n, a) => n + a.size, 0);
487
+ for (const b of bundles) {
488
+ console.log(` ${b.platform.padEnd(8)} runtime ${runtimeVersions.get(b.platform)} bundle ${kb(b.launchAsset.size)} ${b.assets.length} assets`);
489
+ }
490
+ if (!cfg) {
491
+ console.log(`✔ Dry run: ${allAssets.size} unique files, ${kb(totalBytes)}. Nothing uploaded.`);
492
+ return;
493
+ }
494
+ const { missing } = await api(cfg, appPath(cfg, "/assets/missing"), {
495
+ method: "POST",
496
+ json: { keys: [...allAssets.keys()] }
497
+ });
498
+ const toUpload = missing.map((k) => allAssets.get(k));
499
+ 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);
502
+ await retry(() => api(cfg, appPath(cfg, `/assets/${a.key}`), {
503
+ method: "PUT",
504
+ body,
505
+ headers: { "content-type": a.contentType, "x-file-extension": a.fileExtension }
506
+ }));
507
+ });
508
+ const res = await api(cfg, appPath(cfg, "/updates"), {
509
+ method: "POST",
510
+ json: {
511
+ channel: opts.channel,
512
+ message: opts.message,
513
+ gitSha,
514
+ rolloutPct: opts.rolloutPct,
515
+ updates: bundles.map((b) => ({
516
+ platform: b.platform,
517
+ runtimeVersion: runtimeVersions.get(b.platform),
518
+ launchAssetKey: b.launchAsset.key,
519
+ assetKeys: b.assets.map((a) => a.key),
520
+ extra: { expoClient }
521
+ }))
522
+ }
523
+ });
524
+ console.log(`✔ Published to ${opts.channel} at ${opts.rolloutPct}% (group ${res.groupId})`);
525
+ for (const u of res.updates)
526
+ console.log(` ${u.platform.padEnd(8)} ${u.id}`);
527
+ } finally {
528
+ if (tempDir)
529
+ await rm2(tempDir, { recursive: true, force: true });
530
+ }
531
+ }
532
+ var kb = (n) => n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${(n / 1024).toFixed(0)} KB`;
533
+ async function retry(fn, attempts = 4, baseDelayMs = 1000) {
534
+ for (let i = 1;; i++) {
535
+ try {
536
+ return await fn();
537
+ } catch (e) {
538
+ if (i >= attempts || e instanceof ApiError && e.status < 500)
539
+ throw e;
540
+ await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** i));
541
+ }
542
+ }
543
+ }
544
+ async function pool(items, size, fn) {
545
+ const queue = [...items];
546
+ await Promise.all(Array.from({ length: Math.min(size, queue.length) }, async () => {
547
+ for (let item = queue.shift();item !== undefined; item = queue.shift())
548
+ await fn(item);
549
+ }));
550
+ }
551
+
552
+ // src/index.ts
553
+ var HELP = `tidepush: publish and manage Expo OTA updates on a Tidepush server
554
+
555
+ Usage
556
+ tidepush login [--web <url>] [--no-open]
557
+ tidepush logout
558
+ tidepush init [--app <appId> | --create-app <name>] [--org <orgId>] [--channel <name>] [--app-dir <dir>]
559
+ tidepush publish --channel <name> [--platform ios|android|all] [--rollout <pct>] [-m <message>]
560
+ [--app-dir <dir>] [--runtime-version <v>] [--dist <dir>] [--dry-run]
561
+ tidepush promote <updateOrGroupId> --to <channel> [--rollout <pct>]
562
+ tidepush rollout <updateOrGroupId> <pct>
563
+ tidepush rollback <updateOrGroupId> [--to-embedded]
564
+ tidepush list [--channel <name>] [--platform ios|android] [--runtime-version <v>] [--limit <n>] [--json]
565
+
566
+ Which app
567
+ --app <appId> or TIDEPUSH_APP_ID. Otherwise read from updates.url in the app's config
568
+ (https://…/<appId>/manifest). Without any, the server uses the org's only app.
569
+
570
+ Signing in
571
+ On your machine, \`tidepush login\` (saved in ~/.config/tidepush/credentials.json).
572
+ In CI, TIDEPUSH_TOKEN: a tp_… API token from the dashboard (Settings → API tokens).
573
+
574
+ Environment (bun loads .env automatically)
575
+ TIDEPUSH_TOKEN API token (TIDEPUSH_API_TOKEN also works)
576
+ TIDEPUSH_SERVER_URL API server for tokens, e.g. https://ota.example.com (or --server). Default: hosted.
577
+ TIDEPUSH_WEB_URL web app for \`login\`. Default: hosted.
578
+ TIDEPUSH_APP_DIR app directory for publish (default: cwd, or --app-dir)
579
+ TIDEPUSH_ACCESS_CLIENT_ID / TIDEPUSH_ACCESS_CLIENT_SECRET
580
+ Access service token, if the server hostname is behind Cloudflare Access
581
+
582
+ publish runs \`expo export\` in the app with TIDEPUSH_CHANNEL=<channel> set, so the app's
583
+ app.config.ts can set updates.requestHeaders["expo-channel-name"] from it.
584
+ `;
585
+ if (!("Bun" in globalThis)) {
586
+ try {
587
+ process.loadEnvFile(".env");
588
+ } catch {}
589
+ }
590
+ async function main(argv) {
591
+ const [command, ...rest] = argv;
592
+ if (command === "--version" || command === "-v" || command === "version") {
593
+ console.log("0.1.0");
594
+ return;
595
+ }
596
+ if (!command || command === "help" || command === "--help" || command === "-h") {
597
+ console.log(HELP);
598
+ return;
599
+ }
600
+ const { values, positionals } = parseArgs({
601
+ args: rest,
602
+ allowPositionals: true,
603
+ options: {
604
+ channel: { type: "string" },
605
+ platform: { type: "string" },
606
+ rollout: { type: "string" },
607
+ message: { type: "string", short: "m" },
608
+ "app-dir": { type: "string" },
609
+ "runtime-version": { type: "string" },
610
+ dist: { type: "string" },
611
+ "dry-run": { type: "boolean" },
612
+ to: { type: "string" },
613
+ "to-embedded": { type: "boolean" },
614
+ limit: { type: "string" },
615
+ server: { type: "string" },
616
+ app: { type: "string" },
617
+ web: { type: "string" },
618
+ org: { type: "string" },
619
+ "create-app": { type: "string" },
620
+ "no-open": { type: "boolean" },
621
+ json: { type: "boolean" }
622
+ }
623
+ });
624
+ const pct = (v, name) => {
625
+ if (v === undefined)
626
+ return;
627
+ const n = Number(v);
628
+ if (!Number.isInteger(n) || n < 0 || n > 100)
629
+ throw new Error(`${name} must be an integer 0–100`);
630
+ return n;
631
+ };
632
+ const need = (v, what) => {
633
+ if (!v)
634
+ throw new Error(`Missing ${what}. Run \`tidepush help\`.`);
635
+ return v;
636
+ };
637
+ const dryRun = values["dry-run"] ?? false;
638
+ const appDir = values["app-dir"] ?? process.env.TIDEPUSH_APP_DIR ?? process.cwd();
639
+ const credentials = await readCredentials();
640
+ const cfg = (detectApp = true) => apiConfigFromEnv({ server: values.server, app: values.app ?? (detectApp ? appIdFromAppJson(appDir) : undefined) }, process.env, credentials);
641
+ switch (command) {
642
+ case "login":
643
+ return login({ webUrl: values.web, open: !values["no-open"] });
644
+ case "logout":
645
+ return logout();
646
+ case "init":
647
+ return init(cfg(false), {
648
+ appDir,
649
+ appId: values.app,
650
+ orgId: values.org,
651
+ createApp: values["create-app"],
652
+ channel: values.channel
653
+ });
654
+ case "publish": {
655
+ const platform = values.platform ?? "all";
656
+ if (!["ios", "android", "all"].includes(platform))
657
+ throw new Error("--platform must be ios, android or all");
658
+ await publish(dryRun ? null : cfg(false), {
659
+ channel: need(values.channel, "--channel"),
660
+ platforms: platform === "all" ? ["ios", "android"] : [platform],
661
+ rolloutPct: pct(values.rollout, "--rollout") ?? 100,
662
+ message: values.message,
663
+ appDir,
664
+ runtimeVersion: values["runtime-version"],
665
+ distDir: values.dist
666
+ });
667
+ return;
668
+ }
669
+ case "promote":
670
+ return promote(cfg(), need(positionals[0], "update id"), need(values.to, "--to <channel>"), pct(values.rollout, "--rollout"));
671
+ case "rollout":
672
+ return rollout(cfg(), need(positionals[0], "update id"), pct(need(positionals[1], "percentage"), "percentage"));
673
+ case "rollback":
674
+ return rollback(cfg(), need(positionals[0], "update id"), values["to-embedded"] ?? false);
675
+ case "list":
676
+ return list(cfg(), {
677
+ channel: values.channel,
678
+ platform: values.platform,
679
+ runtimeVersion: values["runtime-version"],
680
+ limit: values.limit,
681
+ json: values.json ?? false
682
+ });
683
+ default:
684
+ throw new Error(`Unknown command "${command}". Run \`tidepush help\`.`);
685
+ }
686
+ }
687
+ function appIdFromAppJson(appDir) {
688
+ try {
689
+ const config = JSON.parse(readFileSync(join5(appDir, "app.json"), "utf8"));
690
+ return appIdFromUpdatesUrl((config.expo ?? config).updates?.url);
691
+ } catch {
692
+ return;
693
+ }
694
+ }
695
+ main(process.argv.slice(2)).catch((e) => {
696
+ console.error(`✖ ${e instanceof ApiError || e instanceof Error ? e.message : String(e)}`);
697
+ process.exit(1);
698
+ });