@bipopush/cli 0.2.0 → 0.3.0-beta.11

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/commands.js DELETED
@@ -1,303 +0,0 @@
1
- import path from "node:path";
2
- import { API_URL } from "./config.js";
3
- import { ApiError, request } from "./http.js";
4
- import { generateSigningKey, keyFile, loadSigningKey, saveSigningKey } from "./keys.js";
5
- import { decompress, packPlatform, sha256Hex } from "./pack.js";
6
- import { PROJECT_FILE, detectProject, loadProjectConfig, saveProjectConfig, addAndroidUrlScheme, addIosUrlScheme, writeExpoConfig, writeAndroidManifest, writeIosInfoPlist, } from "./project.js";
7
- const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
8
- function nativeFields(project, scheme) {
9
- return {
10
- iosBundleId: project.ios.id,
11
- androidPackage: project.android.id,
12
- scheme: scheme ?? project.ios.scheme ?? project.android.scheme,
13
- };
14
- }
15
- function describeProject(project) {
16
- console.log(`Project: ${project.name} (${project.type === "expo" ? "Expo" : "React Native"})`);
17
- console.log(` iOS ${project.ios.id ?? "—"} version ${project.ios.version ?? "—"}`);
18
- console.log(` Android ${project.android.id ?? "—"} version ${project.android.version ?? "—"}`);
19
- console.log(` Scheme ${project.ios.scheme ?? project.android.scheme ?? "— (deeplinks need one: --scheme <name>)"}`);
20
- }
21
- /**
22
- * Registers this project as an app (or links it again), creates the signing key pair,
23
- * writes bipopush.json and puts the app key + public key in the iOS Info.plist.
24
- */
25
- export async function init(token, opts) {
26
- const project = detectProject(opts.cwd);
27
- if (opts.scheme && project.type === "expo" && !project.ios.scheme) {
28
- project.ios.scheme = opts.scheme;
29
- project.android.scheme = opts.scheme;
30
- }
31
- else if (opts.scheme && project.iosInfoPlist && !project.ios.scheme) {
32
- addIosUrlScheme(project.iosInfoPlist, opts.scheme);
33
- project.ios.scheme = opts.scheme;
34
- console.log(`Added URL scheme "${opts.scheme}" to ${path.relative(opts.cwd, project.iosInfoPlist)}`);
35
- }
36
- describeProject(project);
37
- const existing = loadProjectConfig(opts.cwd);
38
- const appId = opts.appId ?? existing?.appId;
39
- let app;
40
- let privateKeyNote;
41
- if (appId) {
42
- ({ app } = await request(`/apps/${appId}`, { token }));
43
- ({ app } = await request(`/apps/${app.id}`, {
44
- method: "PATCH",
45
- token,
46
- headers: { "content-type": "application/json" },
47
- body: JSON.stringify(nativeFields(project, opts.scheme ?? null)),
48
- }));
49
- if (opts.newSigningKey) {
50
- // Lost key: register a new public key. Builds already installed only trust the old one,
51
- // so they ignore new releases until users install a store build with the new key.
52
- const { privateKey, publicKey } = generateSigningKey();
53
- ({ app } = await request(`/apps/${app.id}`, {
54
- method: "PATCH",
55
- token,
56
- headers: { "content-type": "application/json" },
57
- body: JSON.stringify({ publicKey }),
58
- }));
59
- privateKeyNote = saveSigningKey(app.id, privateKey);
60
- console.log("\nNew signing key registered. Ship a new store build: installed builds only trust the old key.");
61
- }
62
- else {
63
- loadSigningKey(app.id);
64
- privateKeyNote = `existing key ${keyFile(app.id)}`;
65
- }
66
- console.log(`\nLinked to existing app "${app.name}".`);
67
- }
68
- else {
69
- const org = await pickOrganization(token, opts.org);
70
- const { privateKey, publicKey } = generateSigningKey();
71
- ({ app } = await request("/apps", {
72
- method: "POST",
73
- token,
74
- headers: { "content-type": "application/json" },
75
- body: JSON.stringify({ organizationId: org.id, name: opts.name ?? project.name, publicKey, ...nativeFields(project, opts.scheme ?? null) }),
76
- }));
77
- privateKeyNote = saveSigningKey(app.id, privateKey);
78
- console.log(`\nCreated app "${app.name}" in ${org.name}.`);
79
- }
80
- const config = { appId: app.id, appKey: app.appKey, type: project.type, entryFile: project.entryFile };
81
- saveProjectConfig(opts.cwd, config);
82
- if (project.type === "expo") {
83
- const plugin = { appKey: app.appKey, publicKey: app.publicKey };
84
- if (API_URL !== "https://api.bipopush.com")
85
- plugin.apiUrl = API_URL;
86
- writeExpoConfig(opts.cwd, plugin, opts.scheme);
87
- console.log("Configured the @bipopush/react-native plugin in app.json (run npx expo prebuild to apply).");
88
- }
89
- else {
90
- const values = { BipoPushAppKey: app.appKey, BipoPushPublicKey: app.publicKey };
91
- if (API_URL !== "https://api.bipopush.com")
92
- values.BipoPushApiUrl = API_URL;
93
- if (project.iosInfoPlist) {
94
- writeIosInfoPlist(project.iosInfoPlist, { BipoPushApiUrl: null, ...values });
95
- console.log(`Wrote BipoPushAppKey / BipoPushPublicKey to ${path.relative(opts.cwd, project.iosInfoPlist)}`);
96
- }
97
- if (project.androidManifest) {
98
- writeAndroidManifest(project.androidManifest, { BipoPushApiUrl: null, ...values });
99
- console.log(`Wrote BipoPushAppKey / BipoPushPublicKey to ${path.relative(opts.cwd, project.androidManifest)}`);
100
- const scheme = opts.scheme ?? project.ios.scheme;
101
- if (scheme && !project.android.scheme) {
102
- addAndroidUrlScheme(project.androidManifest, scheme);
103
- project.android.scheme = scheme;
104
- console.log(`Added URL scheme "${scheme}" to ${path.relative(opts.cwd, project.androidManifest)}`);
105
- }
106
- }
107
- }
108
- console.log(`\nApp key: ${app.appKey}`);
109
- console.log(`Public key: ${app.publicKey}`);
110
- console.log(`Signing key: ${privateKeyNote}`);
111
- console.log(`\nKeep the signing key private. In CI, set BIPOPUSH_SIGNING_KEY to the base64 of that file.`);
112
- console.log(`Saved ${PROJECT_FILE} — commit it (it has no secrets).`);
113
- console.log(`Next: rebuild the native app once, then run: bipopush release`);
114
- }
115
- async function fetchBase(token, appId, channel, platform, appVersion) {
116
- const { base } = await request(`/apps/${appId}/base?${new URLSearchParams({ channel, platform, appVersion })}`, { token });
117
- if (!base)
118
- return null;
119
- const res = await fetch(base.url);
120
- if (!res.ok)
121
- throw new Error(`Could not download the previous release (${res.status})`);
122
- const hbc = decompress(Buffer.from(await res.arrayBuffer()));
123
- if (sha256Hex(hbc) !== base.sha256)
124
- throw new Error("Previous release failed its integrity check");
125
- return { artifactId: base.artifactId, sha256: base.sha256, hbc };
126
- }
127
- async function put(url, data) {
128
- const res = await fetch(url, {
129
- method: "PUT",
130
- headers: { "content-type": "application/octet-stream", "content-length": String(data.length) },
131
- body: new Uint8Array(data),
132
- });
133
- if (!res.ok)
134
- throw new Error(`Upload failed (${res.status}): ${(await res.text()).slice(0, 200)}`);
135
- }
136
- function summary(a) {
137
- const saved = 100 - (a.full.length / a.jsSize) * 100;
138
- console.log(`\n${a.platform} ${a.appVersion}`);
139
- console.log(` JS ${kb(a.jsSize)} → Hermes ${kb(a.bundleSize)} → brotli ${kb(a.full.length)} (${saved.toFixed(0)}% smaller)`);
140
- if (a.patch) {
141
- console.log(` patch ${kb(a.patch.data.length)} vs previous release (${(100 - (a.patch.data.length / a.full.length) * 100).toFixed(0)}% smaller than full)`);
142
- }
143
- else {
144
- console.log(` no patch: ${a.patchNote}`);
145
- }
146
- if (a.assets.length > 0) {
147
- console.log(` assets ${a.assets.length} files, ${kb(a.assets.reduce((n, x) => n + x.size, 0))} (manifest signed)`);
148
- }
149
- console.log(` sha256 ${a.sha256.slice(0, 16)}… signed ✓`);
150
- }
151
- /** Bundles, compiles, signs, compresses, diffs and uploads one release. */
152
- export async function release(token, opts) {
153
- const config = loadProjectConfig(opts.cwd);
154
- if (!config)
155
- throw new Error(`No ${PROJECT_FILE} here. Run: bipopush init`);
156
- const project = detectProject(opts.cwd);
157
- const privateKey = loadSigningKey(config.appId);
158
- const { app } = await request(`/apps/${config.appId}`, { token });
159
- const wanted = opts.platform === "all" ? ["ios", "android"] : [opts.platform];
160
- const platforms = wanted.filter((p) => project[p].id && project[p].version);
161
- if (platforms.length === 0)
162
- throw new Error(`No native ${wanted.join("/")} project found to release for`);
163
- console.log(`Releasing ${app.name} → ${opts.channel} (${platforms.join(", ")})`);
164
- const packed = [];
165
- for (const platform of platforms) {
166
- const appVersion = project[platform].version;
167
- const base = await fetchBase(token, app.id, opts.channel, platform, appVersion);
168
- packed.push(await packPlatform({ project, platform, appVersion, entryFile: config.entryFile ?? project.entryFile, privateKey, base }));
169
- }
170
- packed.forEach(summary);
171
- if (opts.dryRun) {
172
- console.log("\nDry run: nothing uploaded.");
173
- return;
174
- }
175
- // Assets are stored once per app: upload only the hashes the server does not have.
176
- const allAssets = new Map();
177
- for (const a of packed)
178
- for (const x of a.assets)
179
- allAssets.set(x.sha256, { sha256: x.sha256, size: x.size, data: a.assetFiles.get(x.sha256) });
180
- if (allAssets.size > 0) {
181
- const { uploads } = await request(`/apps/${app.id}/assets`, {
182
- method: "POST",
183
- token,
184
- headers: { "content-type": "application/json" },
185
- body: JSON.stringify({ assets: [...allAssets.values()].map(({ sha256, size }) => ({ sha256, size })) }),
186
- });
187
- for (const u of uploads)
188
- await put(u.url, allAssets.get(u.sha256).data);
189
- const bytes = uploads.reduce((n, u) => n + allAssets.get(u.sha256).size, 0);
190
- console.log(`\nAssets: ${uploads.length} new uploaded (${kb(bytes)}), ${allAssets.size - uploads.length} already stored.`);
191
- }
192
- const created = await waitingForBurst(() => request(`/apps/${app.id}/releases`, {
193
- method: "POST",
194
- token,
195
- headers: { "content-type": "application/json" },
196
- body: JSON.stringify({
197
- channel: opts.channel,
198
- message: opts.message,
199
- mandatory: Boolean(opts.mandatory),
200
- artifacts: packed.map((a) => ({
201
- platform: a.platform,
202
- appVersion: a.appVersion,
203
- sha256: a.sha256,
204
- signature: a.signature,
205
- bundleSize: a.bundleSize,
206
- fullSize: a.full.length,
207
- ...(a.patch ? { patch: { baseArtifactId: a.patch.baseArtifactId, size: a.patch.data.length } } : {}),
208
- ...(a.assets.length > 0 ? { assets: a.assets } : {}),
209
- })),
210
- }),
211
- }));
212
- for (const upload of created.uploads) {
213
- const a = packed.find((p) => p.platform === upload.platform);
214
- await put(upload.fullUrl, a.full);
215
- if (upload.patchUrl && a.patch)
216
- await put(upload.patchUrl, a.patch.data);
217
- }
218
- const { release: published, usage } = await waitingForBurst(() => request(`/releases/${created.releaseId}/publish`, { method: "POST", token }));
219
- const labels = published.artifacts.map((a) => `${a.platform === "ios" ? "iOS" : "Android"} ${a.appVersion} v${a.version}`);
220
- console.log(`\nPublished ${labels.join(", ")} to ${opts.channel}${opts.mandatory ? " (mandatory)" : ""}.`);
221
- console.log(`${usage.used} release${usage.used === 1 ? "" : "s"} of this app in the last 24h.`);
222
- }
223
- /** Waits out a release pause (going over the plan's releases per 10 minutes) instead of failing, e.g. in CI. */
224
- async function waitingForBurst(call) {
225
- for (;;) {
226
- try {
227
- return await call();
228
- }
229
- catch (err) {
230
- if (!(err instanceof ApiError) || err.body.error !== "release_too_soon")
231
- throw err;
232
- const wait = Number(err.body.retryAfterSeconds);
233
- if (!(wait > 0 && wait <= 31 * 60))
234
- throw err;
235
- console.log(`${err.message} Waiting ${wait}s…`);
236
- await new Promise((r) => setTimeout(r, wait * 1000));
237
- }
238
- }
239
- }
240
- export async function memberships(token) {
241
- return (await request("/me", { token })).organizations;
242
- }
243
- /** The organization new apps go to: --org (id, slug or name), or the only one the user can create apps in. */
244
- async function pickOrganization(token, ref) {
245
- const orgs = (await memberships(token)).filter((o) => o.role.permissions.includes("app:create"));
246
- if (ref) {
247
- const wanted = ref.toLowerCase();
248
- const match = orgs.find((o) => [o.id, o.slug.current, o.slug.hash, o.slug.custom, o.name.toLowerCase()].includes(wanted));
249
- if (!match)
250
- throw new Error(`No organization "${ref}" where you can create apps. Run: bipopush whoami`);
251
- return match;
252
- }
253
- if (orgs.length === 1)
254
- return orgs[0];
255
- if (orgs.length === 0)
256
- throw new Error("Your role can't create apps in any organization. Ask an owner or admin.");
257
- const list = orgs.map((o) => ` --org ${o.slug.current} ${o.name}`).join("\n");
258
- throw new Error(`You belong to several organizations. Pick one:\n${list}`);
259
- }
260
- export async function listApps(token) {
261
- const { apps, organizations } = await request("/apps", { token });
262
- if (apps.length === 0)
263
- console.log("No apps yet. Run: bipopush init");
264
- for (const org of organizations) {
265
- const own = apps.filter((a) => a.organization.id === org.id);
266
- const limit = org.maxApps === null ? "unlimited" : `${own.length}/${org.maxApps}`;
267
- console.log(`\n${org.name} (${org.plan}, ${limit} apps)`);
268
- for (const a of own) {
269
- console.log(` ${a.name} ${a.id}\n key ${a.appKey} · ${a.mau} MAU · ${a.usage.used}/${a.usage.limit} releases today`);
270
- }
271
- }
272
- }
273
- /**
274
- * Re-publishes the newest live version of a channel (or a given --version) in another channel,
275
- * e.g. staging → production after testing. Same bundle and signature; does not use the daily quota.
276
- */
277
- export async function promote(token, opts) {
278
- const config = loadProjectConfig(opts.cwd);
279
- if (!config)
280
- throw new Error(`No ${PROJECT_FILE} here. Run: bipopush init`);
281
- if (opts.from === opts.to)
282
- throw new Error("--from and --to must be different channels");
283
- const { releases } = await request(`/apps/${config.appId}/releases`, { token });
284
- const candidates = releases
285
- .filter((r) => r.channel === opts.from)
286
- .flatMap((r) => r.artifacts)
287
- .filter((a) => !a.disabled && !a.fileDeleted && (opts.platform === "all" || a.platform === opts.platform));
288
- const picked = new Map();
289
- for (const a of candidates) {
290
- if (opts.version !== undefined && a.version !== opts.version)
291
- continue;
292
- const key = `${a.platform}/${a.appVersion}`;
293
- if (!picked.has(key) || picked.get(key).version < a.version)
294
- picked.set(key, a);
295
- }
296
- if (picked.size === 0)
297
- throw new Error(`No live ${opts.version ? `v${opts.version} ` : ""}version in ${opts.from} to promote`);
298
- for (const a of picked.values()) {
299
- const { release: r } = await request(`/apps/${config.appId}/artifacts/${a.id}/promote`, { method: "POST", token, headers: { "content-type": "application/json" }, body: JSON.stringify({ channel: opts.to }) });
300
- const label = `${a.platform === "ios" ? "iOS" : "Android"} ${a.appVersion}`;
301
- console.log(`Promoted ${label} ${opts.from} v${a.version} → ${opts.to} v${r.artifacts[0].version}`);
302
- }
303
- }
package/dist/config.js DELETED
@@ -1,29 +0,0 @@
1
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import path from "node:path";
4
- export const API_URL = (process.env.BIPOPUSH_API_URL ?? "https://api.bipopush.com").replace(/\/$/, "");
5
- export const SIGN_URL = (process.env.BIPOPUSH_SIGN_URL ?? "https://sign.bipopush.com").replace(/\/$/, "");
6
- export const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config"), "bipopush");
7
- const dir = CONFIG_DIR;
8
- export const CREDENTIALS_FILE = path.join(dir, "credentials.json");
9
- export async function loadCredentials() {
10
- if (process.env.BIPOPUSH_TOKEN) {
11
- return { apiUrl: API_URL, token: process.env.BIPOPUSH_TOKEN, email: "(BIPOPUSH_TOKEN)" };
12
- }
13
- try {
14
- const creds = JSON.parse(await readFile(CREDENTIALS_FILE, "utf8"));
15
- // A token is only valid for the API that issued it.
16
- return creds.apiUrl === API_URL ? creds : null;
17
- }
18
- catch {
19
- return null;
20
- }
21
- }
22
- export async function saveCredentials(creds) {
23
- await mkdir(dir, { recursive: true, mode: 0o700 });
24
- await writeFile(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
25
- await chmod(CREDENTIALS_FILE, 0o600);
26
- }
27
- export async function clearCredentials() {
28
- await rm(CREDENTIALS_FILE, { force: true });
29
- }
package/dist/http.js DELETED
@@ -1,41 +0,0 @@
1
- import { API_URL } from "./config.js";
2
- export class ApiError extends Error {
3
- status;
4
- body;
5
- constructor(status, body) {
6
- super(body.message ?? `Request failed with status ${status}`);
7
- this.status = status;
8
- this.body = body;
9
- }
10
- }
11
- const STALE_SOCKET = new Set(["ECONNRESET", "EPIPE", "UND_ERR_SOCKET"]);
12
- export async function request(path, init = {}) {
13
- const headers = new Headers(init.headers);
14
- headers.set("user-agent", `bipopush-cli/0.1.0 node/${process.versions.node} ${process.platform}`);
15
- if (init.token)
16
- headers.set("authorization", `Bearer ${init.token}`);
17
- let res;
18
- for (let attempt = 0; !res; attempt++) {
19
- try {
20
- res = await fetch(`${API_URL}${path}`, { ...init, headers });
21
- }
22
- catch (err) {
23
- const cause = err.cause;
24
- // A pooled keep-alive socket can be closed by the server while a long build runs: retry once on a fresh one.
25
- if (attempt === 0 && cause?.code && STALE_SOCKET.has(cause.code))
26
- continue;
27
- throw new Error(`Could not reach ${API_URL} (${cause?.code ?? cause?.message ?? err.message})`);
28
- }
29
- }
30
- const text = await res.text();
31
- let body = {};
32
- try {
33
- body = text ? JSON.parse(text) : {};
34
- }
35
- catch {
36
- body = { message: text.slice(0, 200) };
37
- }
38
- if (!res.ok)
39
- throw new ApiError(res.status, body);
40
- return body;
41
- }
package/dist/keys.js DELETED
@@ -1,41 +0,0 @@
1
- import { createPrivateKey, createPublicKey, generateKeyPairSync, sign } from "node:crypto";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import path from "node:path";
4
- import { CONFIG_DIR } from "./config.js";
5
- /**
6
- * Release signing keys (ed25519). The private key stays on the developer machine or in CI
7
- * (BIPOPUSH_SIGNING_KEY); the public key is embedded in the app and stored on the server.
8
- */
9
- export function keyFile(appId) {
10
- return path.join(CONFIG_DIR, "keys", `${appId}.pem`);
11
- }
12
- export function rawPublicKey(key) {
13
- return createPublicKey(key).export({ format: "der", type: "spki" }).subarray(-32).toString("base64");
14
- }
15
- export function generateSigningKey() {
16
- const { privateKey } = generateKeyPairSync("ed25519");
17
- return { privateKey, publicKey: rawPublicKey(privateKey) };
18
- }
19
- export function saveSigningKey(appId, privateKey) {
20
- const file = keyFile(appId);
21
- mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
22
- writeFileSync(file, privateKey.export({ format: "pem", type: "pkcs8" }), { mode: 0o600 });
23
- chmodSync(file, 0o600);
24
- return file;
25
- }
26
- export function loadSigningKey(appId) {
27
- const fromEnv = process.env.BIPOPUSH_SIGNING_KEY;
28
- if (fromEnv) {
29
- const pem = fromEnv.includes("BEGIN") ? fromEnv : Buffer.from(fromEnv, "base64").toString("utf8");
30
- return createPrivateKey(pem);
31
- }
32
- const file = keyFile(appId);
33
- if (!existsSync(file)) {
34
- throw new Error(`Signing key not found at ${file}. Set BIPOPUSH_SIGNING_KEY or run bipopush init on the machine that has the key.`);
35
- }
36
- return createPrivateKey(readFileSync(file));
37
- }
38
- /** Signs the raw 32-byte SHA-256 digest of the uncompressed bundle. */
39
- export function signDigest(privateKey, sha256Hex) {
40
- return sign(null, Buffer.from(sha256Hex, "hex"), privateKey).toString("base64");
41
- }
package/dist/login.js DELETED
@@ -1,73 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { createHash, randomBytes } from "node:crypto";
3
- import { createServer } from "node:http";
4
- import { API_URL, SIGN_URL, saveCredentials } from "./config.js";
5
- import { request } from "./http.js";
6
- const TIMEOUT_MS = 5 * 60 * 1000;
7
- function openBrowser(url) {
8
- if (process.env.BIPOPUSH_NO_BROWSER)
9
- return;
10
- const [cmd, args] = process.platform === "darwin"
11
- ? ["open", [url]]
12
- : process.platform === "win32"
13
- ? ["cmd", ["/c", "start", "", url]]
14
- : ["xdg-open", [url]];
15
- spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
16
- }
17
- const page = (title, text) => `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:system-ui;display:grid;place-items:center;height:100vh;margin:0"><div style="text-align:center"><h1>${title}</h1><p>${text}</p></div>`;
18
- /**
19
- * Browser-based login (PKCE):
20
- * 1. Listen on 127.0.0.1:<random port>.
21
- * 2. Open sign.bipopush.com/cli with the PKCE challenge, state and port.
22
- * 3. After the user approves, the browser is redirected to our callback with a one-time code.
23
- * 4. Exchange code + verifier for a CLI token.
24
- */
25
- export async function login() {
26
- const verifier = randomBytes(48).toString("base64url");
27
- const challenge = createHash("sha256").update(verifier).digest("base64url");
28
- const state = randomBytes(24).toString("base64url");
29
- const code = await new Promise((resolve, reject) => {
30
- const server = createServer((req, res) => {
31
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
32
- if (url.pathname !== "/callback") {
33
- res.writeHead(404).end();
34
- return;
35
- }
36
- const finish = (status, title, text, result) => {
37
- res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(page(title, text));
38
- clearTimeout(timer);
39
- server.close();
40
- result();
41
- };
42
- if (url.searchParams.get("state") !== state) {
43
- finish(400, "Login failed", "State mismatch. Run bipopush login again.", () => reject(new Error("State mismatch — possible forged callback")));
44
- return;
45
- }
46
- const error = url.searchParams.get("error");
47
- const code = url.searchParams.get("code");
48
- if (error || !code) {
49
- finish(400, "Login cancelled", "You can close this tab.", () => reject(new Error(error ?? "No code received")));
50
- return;
51
- }
52
- finish(200, "BipoPush CLI is logged in", "You can close this tab and return to your terminal.", () => resolve(code));
53
- });
54
- const timer = setTimeout(() => {
55
- server.close();
56
- reject(new Error("Timed out waiting for the browser (5 minutes)"));
57
- }, TIMEOUT_MS);
58
- server.listen(0, "127.0.0.1", () => {
59
- const { port } = server.address();
60
- const url = `${SIGN_URL}/cli?${new URLSearchParams({ challenge, state, port: String(port) })}`;
61
- console.log("Opening your browser to log in…");
62
- console.log(`If it doesn't open, visit:\n ${url}\n`);
63
- openBrowser(url);
64
- });
65
- });
66
- const { token, user } = await request("/cli/token", {
67
- method: "POST",
68
- headers: { "content-type": "application/json" },
69
- body: JSON.stringify({ code, codeVerifier: verifier }),
70
- });
71
- await saveCredentials({ apiUrl: API_URL, token, email: user.email });
72
- console.log(`Logged in as ${user.email}`);
73
- }
package/dist/pack.js DELETED
@@ -1,174 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
4
- import { createRequire } from "node:module";
5
- import { tmpdir } from "node:os";
6
- import path from "node:path";
7
- import { brotliCompressSync, brotliDecompressSync, constants } from "node:zlib";
8
- import { signDigest } from "./keys.js";
9
- /** A patch is only worth shipping when it is clearly smaller than the compressed full bundle. */
10
- export const PATCH_RATIO = 0.6;
11
- const localRequire = createRequire(import.meta.url);
12
- function binFrom(root, request) {
13
- return createRequire(path.join(root, "package.json")).resolve(request);
14
- }
15
- export function findHermesc(root) {
16
- const os = process.platform === "darwin" ? "osx-bin" : process.platform === "win32" ? "win64-bin" : "linux64-bin";
17
- const exe = process.platform === "win32" ? "hermesc.exe" : "hermesc";
18
- const candidates = [];
19
- for (const pkg of ["hermes-compiler/package.json", "react-native/package.json"]) {
20
- try {
21
- const dir = path.dirname(binFrom(root, pkg));
22
- candidates.push(path.join(dir, "hermesc", os, exe), path.join(dir, "sdks", "hermesc", os, exe));
23
- }
24
- catch {
25
- // package not installed
26
- }
27
- }
28
- const found = candidates.find(existsSync);
29
- if (!found)
30
- throw new Error("hermesc not found — install the app dependencies first (npm install)");
31
- return found;
32
- }
33
- export function sha256Hex(data) {
34
- return createHash("sha256").update(data).digest("hex");
35
- }
36
- export function compress(data) {
37
- return brotliCompressSync(data, {
38
- params: {
39
- [constants.BROTLI_PARAM_QUALITY]: 11,
40
- [constants.BROTLI_PARAM_LGWIN]: 24,
41
- [constants.BROTLI_PARAM_SIZE_HINT]: data.length,
42
- },
43
- });
44
- }
45
- export function decompress(data) {
46
- return brotliDecompressSync(data);
47
- }
48
- /** BSDIFF40 patch (old → new), or null when the optional native addon is unavailable. */
49
- export async function makePatch(oldData, newData, workDir) {
50
- let bsdiff;
51
- try {
52
- bsdiff = localRequire("bsdiff-nodejs");
53
- }
54
- catch {
55
- return null;
56
- }
57
- const oldFile = path.join(workDir, "base.hbc");
58
- const newFile = path.join(workDir, "new.hbc");
59
- const patchFile = path.join(workDir, "bundle.bspatch");
60
- writeFileSync(oldFile, oldData);
61
- writeFileSync(newFile, newData);
62
- await bsdiff.diff(oldFile, newFile, patchFile);
63
- return readFileSync(patchFile);
64
- }
65
- /**
66
- * Canonical manifest digest (same in the API and the native SDK):
67
- * "bipopush-manifest-v1\n" + "bundle <sha256>\n" + one "<sha256> <path>\n" per asset, sorted by path.
68
- */
69
- export function manifestDigestHex(bundleSha256, assets) {
70
- const lines = [...assets].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)).map((a) => `${a.sha256} ${a.path}\n`);
71
- return sha256Hex(Buffer.from(`bipopush-manifest-v1\nbundle ${bundleSha256}\n${lines.join("")}`));
72
- }
73
- /** Walks Metro's assets-dest output; paths are relative to it, which is the folder next to the bundle. */
74
- export function collectAssets(root) {
75
- const assets = [];
76
- const files = new Map();
77
- const walk = (dir) => {
78
- for (const name of readdirSync(dir)) {
79
- if (name === ".DS_Store")
80
- continue;
81
- const full = path.join(dir, name);
82
- if (statSync(full).isDirectory())
83
- walk(full);
84
- else {
85
- const data = readFileSync(full);
86
- const hash = sha256Hex(data);
87
- assets.push({ path: path.relative(root, full).split(path.sep).join("/"), sha256: hash, size: data.length });
88
- files.set(hash, data);
89
- }
90
- }
91
- };
92
- if (existsSync(root))
93
- walk(root);
94
- assets.sort((a, b) => (a.path < b.path ? -1 : 1));
95
- return { assets, files };
96
- }
97
- export function assetsDir(outDir) {
98
- return path.join(outDir, "assets-out");
99
- }
100
- /** Produces the JavaScript bundle with the project's own toolchain (Metro or Expo). */
101
- export function bundleJs(project, platform, entryFile, outDir) {
102
- const out = path.join(outDir, `index.${platform}.bundle`);
103
- const assets = assetsDir(outDir);
104
- mkdirSync(assets, { recursive: true });
105
- if (project.type === "expo") {
106
- const expoCli = binFrom(project.root, "expo/bin/cli");
107
- execFileSync(process.execPath, [expoCli, "export:embed", "--platform", platform, "--dev", "false", "--minify", "false", "--entry-file", entryFile, "--bundle-output", out, "--assets-dest", assets], { cwd: project.root, stdio: "inherit" });
108
- }
109
- else {
110
- const rnCli = binFrom(project.root, "react-native/cli.js");
111
- execFileSync(process.execPath, [rnCli, "bundle", "--platform", platform, "--dev", "false", "--minify", "false", "--entry-file", entryFile, "--bundle-output", out, "--assets-dest", assets], { cwd: project.root, stdio: "inherit" });
112
- }
113
- return out;
114
- }
115
- /** Compiles to Hermes bytecode; with a base, hermesc lays out the output to diff well against it. */
116
- export function compileHermes(hermesc, jsFile, outDir, baseFile) {
117
- const out = path.join(outDir, "index.hbc");
118
- const args = ["-O", "-w", "-emit-binary", `-out=${out}`];
119
- if (baseFile)
120
- args.push(`-base-bytecode=${baseFile}`);
121
- args.push(jsFile);
122
- execFileSync(hermesc, args, { stdio: ["ignore", "inherit", "inherit"] });
123
- return out;
124
- }
125
- export async function packPlatform(opts) {
126
- const work = mkdtempSync(path.join(tmpdir(), `bipopush-${opts.platform}-`));
127
- try {
128
- return await packIn(work, opts);
129
- }
130
- finally {
131
- rmSync(work, { recursive: true, force: true });
132
- }
133
- }
134
- async function packIn(work, opts) {
135
- const jsFile = opts.jsFile ?? bundleJs(opts.project, opts.platform, opts.entryFile, work);
136
- const hermesc = findHermesc(opts.project.root);
137
- let baseFile = null;
138
- if (opts.base) {
139
- baseFile = path.join(work, "base-input.hbc");
140
- writeFileSync(baseFile, opts.base.hbc);
141
- }
142
- const hbc = readFileSync(compileHermes(hermesc, jsFile, work, baseFile));
143
- const sha256 = sha256Hex(hbc);
144
- const full = compress(hbc);
145
- const { assets, files } = collectAssets(opts.assetsRoot ?? assetsDir(work));
146
- // With assets, the signature covers the whole manifest so no file can be swapped.
147
- const signedDigest = assets.length > 0 ? manifestDigestHex(sha256, assets) : sha256;
148
- let patch = null;
149
- let patchNote = "no previous release for this channel and version";
150
- if (opts.base) {
151
- const data = await makePatch(opts.base.hbc, hbc, work);
152
- if (!data)
153
- patchNote = "bsdiff-nodejs not installed";
154
- else if (data.length >= full.length * PATCH_RATIO)
155
- patchNote = `patch ${data.length} B is not smaller than ${PATCH_RATIO * 100}% of full`;
156
- else {
157
- patch = { baseArtifactId: opts.base.artifactId, data };
158
- patchNote = "";
159
- }
160
- }
161
- return {
162
- platform: opts.platform,
163
- appVersion: opts.appVersion,
164
- jsSize: readFileSync(jsFile).length,
165
- bundleSize: hbc.length,
166
- sha256,
167
- signature: signDigest(opts.privateKey, signedDigest),
168
- full,
169
- patch,
170
- patchNote,
171
- assets,
172
- assetFiles: files,
173
- };
174
- }