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