@bipopush/cli 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 +21 -0
- package/README.md +65 -0
- package/dist/commands.js +303 -0
- package/dist/config.js +29 -0
- package/dist/http.js +41 -0
- package/dist/index.js +167 -0
- package/dist/keys.js +41 -0
- package/dist/login.js +73 -0
- package/dist/pack.js +174 -0
- package/dist/project.js +177 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BipoPush
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @bipopush/cli
|
|
2
|
+
|
|
3
|
+
Register a React Native / Expo app and publish signed, compressed OTA updates.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @bipopush/cli login # browser sign-in
|
|
7
|
+
npx @bipopush/cli init # once per project
|
|
8
|
+
npx @bipopush/cli release # every JS update
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Installed as a dev dependency, the binary is `bipopush`.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
| Command | |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `login` / `logout` / `whoami` | Browser login (PKCE + localhost callback); token stored in `~/.config/bipopush/credentials.json` (0600). `whoami` lists your organizations with role and plan |
|
|
18
|
+
| `init [--name] [--scheme] [--org <slug>] [--app <id>] [--new-signing-key]` | Detects Expo vs React Native, reads the iOS bundle id, Android package, native versions and URL scheme, creates the app in your organization (`--org` when you can create apps in more than one) (or links an existing one with `--app`), generates the ed25519 signing key, writes `bipopush.json` and puts `BipoPushAppKey` / `BipoPushPublicKey` (and the scheme) in `Info.plist` and `AndroidManifest.xml` (Expo: the plugin entry in `app.json`). `--new-signing-key` replaces a lost key (installed builds need a store update) |
|
|
19
|
+
| `release [-p ios\|android\|all] [-c <channel>] [-m text] [--mandatory] [--dry-run]` | Builds and publishes one release (counts as one, whatever the platforms). `-c` defaults to `production`; a new channel name is created if the plan allows. `--mandatory`: devices with auto update install it right away, also when they skip to a later version |
|
|
20
|
+
| `promote [--from staging] [--to production] [-p …] [--number n]` | Re-publishes a tested version (default: newest live one) in another channel — same bundle and assets, new version number there, doesn't count toward the daily limit |
|
|
21
|
+
| `apps` | Lists your apps by organization, with MAU and release usage |
|
|
22
|
+
|
|
23
|
+
## What `release` does
|
|
24
|
+
|
|
25
|
+
For each platform:
|
|
26
|
+
|
|
27
|
+
1. **Bundle** with the project's own toolchain (`react-native bundle` or `expo export:embed`), collecting the images and fonts the JS requires.
|
|
28
|
+
2. **Compile** to Hermes bytecode with the app's `hermesc` (delta-optimized against the previous release when there is one).
|
|
29
|
+
3. **Hash** (SHA-256) the bundle and every asset, and **sign** with the app's ed25519 key (a manifest of all hashes when there are assets).
|
|
30
|
+
4. **Compress** with brotli (quality 11).
|
|
31
|
+
5. **Diff** against the previous release of the same channel + native version (bsdiff); the patch is kept only when it is under 60% of the compressed full bundle.
|
|
32
|
+
6. **Upload** straight to storage with presigned URLs (files never pass through the API server) — assets the app already has in storage are skipped — then **publish**. The API assigns `v1, v2, …` per channel, platform and native version.
|
|
33
|
+
|
|
34
|
+
Example output for a 2 MB app where only an image changed:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
ios 1.0
|
|
38
|
+
JS 2016.7 KB → Hermes 1279.5 KB → brotli 462.1 KB (77% smaller)
|
|
39
|
+
patch 2.1 KB vs previous release (100% smaller than full)
|
|
40
|
+
assets 3 files, 322.3 KB (manifest signed)
|
|
41
|
+
Assets: 2 new uploaded (0.6 KB), 1 already stored.
|
|
42
|
+
Published iOS 1.0 v7 to production.
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Keys and CI
|
|
46
|
+
|
|
47
|
+
- The signing key is saved at `~/.config/bipopush/keys/<appId>.pem`. Keep it private — whoever has it can ship code to your users.
|
|
48
|
+
- In CI set `BIPOPUSH_TOKEN` (a CLI token) and `BIPOPUSH_SIGNING_KEY` (the PEM, or its base64).
|
|
49
|
+
- `bsdiff-nodejs` is an optional native dependency; without it releases still work, just without patches.
|
|
50
|
+
|
|
51
|
+
## Environment
|
|
52
|
+
|
|
53
|
+
| Variable | Default |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `BIPOPUSH_API_URL` | `https://api.bipopush.com` |
|
|
56
|
+
| `BIPOPUSH_SIGN_URL` | `https://sign.bipopush.com` |
|
|
57
|
+
| `BIPOPUSH_TOKEN` | — |
|
|
58
|
+
| `BIPOPUSH_SIGNING_KEY` | — |
|
|
59
|
+
| `BIPOPUSH_NO_BROWSER` | Print the login URL instead of opening it |
|
|
60
|
+
|
|
61
|
+
## Develop
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm test # runs the real hermesc, brotli and bsdiff on sample bundles
|
|
65
|
+
```
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
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/index.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { init, listApps, promote, release } from "./commands.js";
|
|
4
|
+
import { API_URL, CREDENTIALS_FILE, clearCredentials, loadCredentials } from "./config.js";
|
|
5
|
+
import { ApiError, request } from "./http.js";
|
|
6
|
+
import { login } from "./login.js";
|
|
7
|
+
const HELP = `BipoPush CLI
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
bipopush login Log in through your browser
|
|
11
|
+
bipopush whoami Show the logged-in account, organizations and plans
|
|
12
|
+
bipopush init Register this React Native / Expo project as an app
|
|
13
|
+
--name <name> App name (default: project name)
|
|
14
|
+
--scheme <scheme> URL scheme for deeplinks, if the project has none
|
|
15
|
+
--org <slug> Organization for the new app (needed if you belong to several)
|
|
16
|
+
--app <id> Link to an existing app instead of creating one
|
|
17
|
+
--new-signing-key Replace a lost signing key (installed builds need a store update)
|
|
18
|
+
bipopush release Bundle, sign, compress, diff and publish an OTA update
|
|
19
|
+
-p, --platform ios|android|all (default: all detected)
|
|
20
|
+
-c, --channel <name> production (default), staging, beta… — created if the plan allows
|
|
21
|
+
-m, --message <text> Release note
|
|
22
|
+
--mandatory Devices install it right away (critical fix)
|
|
23
|
+
--dry-run Build and show sizes without uploading
|
|
24
|
+
bipopush promote Re-publish a tested version in another channel (same bundle)
|
|
25
|
+
--from <channel> --to <channel> (default: staging → production)
|
|
26
|
+
-p, --platform ios|android|all (default: all)
|
|
27
|
+
--number <n> Version to promote, e.g. 3 for v3 (default: newest live one)
|
|
28
|
+
bipopush apps List your apps by organization
|
|
29
|
+
bipopush logout Revoke this CLI's token and forget it
|
|
30
|
+
|
|
31
|
+
Environment:
|
|
32
|
+
BIPOPUSH_API_URL API base URL (default https://api.bipopush.com)
|
|
33
|
+
BIPOPUSH_SIGN_URL Sign-in URL (default https://sign.bipopush.com)
|
|
34
|
+
BIPOPUSH_TOKEN Use this token instead of the saved login (CI)
|
|
35
|
+
BIPOPUSH_SIGNING_KEY Release signing key, PEM or base64 of the PEM (CI)
|
|
36
|
+
`;
|
|
37
|
+
async function requireToken() {
|
|
38
|
+
const creds = await loadCredentials();
|
|
39
|
+
if (!creds) {
|
|
40
|
+
throw new Error(`Not logged in to ${API_URL}. Run: bipopush login`);
|
|
41
|
+
}
|
|
42
|
+
return creds.token;
|
|
43
|
+
}
|
|
44
|
+
function describeUsage(usage) {
|
|
45
|
+
const next = usage.nextAvailableAt ? ` Resets at ${new Date(usage.nextAvailableAt).toLocaleString()}.` : "";
|
|
46
|
+
return `${usage.used}/${usage.limit} releases used for this app in the last 24h.${next}`;
|
|
47
|
+
}
|
|
48
|
+
async function whoami() {
|
|
49
|
+
const token = await requireToken();
|
|
50
|
+
const me = await request("/me", { token });
|
|
51
|
+
console.log(me.user.email);
|
|
52
|
+
for (const org of me.organizations) {
|
|
53
|
+
const plan = me.plans.find((p) => p.id === org.plan);
|
|
54
|
+
const channels = plan?.unlimitedChannels ? `unlimited channels (fair use ${plan.maxChannels})` : `${plan?.maxChannels} channel(s) per app`;
|
|
55
|
+
const limits = plan ? ` — ${plan.maxApps ?? "unlimited"} app(s), ${channels}, ${plan.mau.toLocaleString()} MAU` : "";
|
|
56
|
+
console.log(` ${org.name} (--org ${org.slug.current}) · ${org.role.name} · ${plan?.name ?? org.plan} plan${limits}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function logout() {
|
|
60
|
+
const creds = await loadCredentials();
|
|
61
|
+
if (creds) {
|
|
62
|
+
await request("/auth/logout", { method: "POST", token: creds.token }).catch(() => undefined);
|
|
63
|
+
}
|
|
64
|
+
await clearCredentials();
|
|
65
|
+
console.log("Logged out.");
|
|
66
|
+
}
|
|
67
|
+
function channelArg(value, fallback, flag) {
|
|
68
|
+
const name = (value ?? fallback).trim().toLowerCase();
|
|
69
|
+
if (!/^[a-z][a-z0-9-]{1,31}$/.test(name))
|
|
70
|
+
throw new Error(`${flag} must be a channel name like production, staging or beta`);
|
|
71
|
+
return name;
|
|
72
|
+
}
|
|
73
|
+
function oneOf(value, allowed, fallback, flag) {
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
return fallback;
|
|
76
|
+
if (!allowed.includes(value))
|
|
77
|
+
throw new Error(`${flag} must be one of: ${allowed.join(", ")}`);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
async function main() {
|
|
81
|
+
const { positionals, values } = parseArgs({
|
|
82
|
+
allowPositionals: true,
|
|
83
|
+
options: {
|
|
84
|
+
help: { type: "boolean", short: "h" },
|
|
85
|
+
version: { type: "boolean", short: "v" },
|
|
86
|
+
platform: { type: "string", short: "p" },
|
|
87
|
+
channel: { type: "string", short: "c" },
|
|
88
|
+
message: { type: "string", short: "m" },
|
|
89
|
+
"dry-run": { type: "boolean" },
|
|
90
|
+
mandatory: { type: "boolean" },
|
|
91
|
+
name: { type: "string" },
|
|
92
|
+
scheme: { type: "string" },
|
|
93
|
+
app: { type: "string" },
|
|
94
|
+
org: { type: "string" },
|
|
95
|
+
"new-signing-key": { type: "boolean" },
|
|
96
|
+
from: { type: "string" },
|
|
97
|
+
to: { type: "string" },
|
|
98
|
+
number: { type: "string" },
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
const [command] = positionals;
|
|
102
|
+
if (values.version)
|
|
103
|
+
return console.log("0.2.0");
|
|
104
|
+
if (values.help || !command)
|
|
105
|
+
return console.log(HELP);
|
|
106
|
+
const cwd = process.cwd();
|
|
107
|
+
switch (command) {
|
|
108
|
+
case "login":
|
|
109
|
+
return login();
|
|
110
|
+
case "whoami":
|
|
111
|
+
return whoami();
|
|
112
|
+
case "init":
|
|
113
|
+
return init(await requireToken(), {
|
|
114
|
+
cwd,
|
|
115
|
+
name: values.name,
|
|
116
|
+
scheme: values.scheme,
|
|
117
|
+
appId: values.app,
|
|
118
|
+
org: values.org,
|
|
119
|
+
newSigningKey: values["new-signing-key"],
|
|
120
|
+
});
|
|
121
|
+
case "release":
|
|
122
|
+
return release(await requireToken(), {
|
|
123
|
+
cwd,
|
|
124
|
+
platform: oneOf(values.platform, ["ios", "android", "all"], "all", "--platform"),
|
|
125
|
+
channel: channelArg(values.channel, "production", "--channel"),
|
|
126
|
+
message: values.message,
|
|
127
|
+
mandatory: values.mandatory,
|
|
128
|
+
dryRun: values["dry-run"],
|
|
129
|
+
});
|
|
130
|
+
case "promote":
|
|
131
|
+
return promote(await requireToken(), {
|
|
132
|
+
cwd,
|
|
133
|
+
from: channelArg(values.from, "staging", "--from"),
|
|
134
|
+
to: channelArg(values.to, "production", "--to"),
|
|
135
|
+
platform: oneOf(values.platform, ["ios", "android", "all"], "all", "--platform"),
|
|
136
|
+
version: values.number ? Number(values.number.replace(/^v/i, "")) : undefined,
|
|
137
|
+
});
|
|
138
|
+
case "apps":
|
|
139
|
+
return listApps(await requireToken());
|
|
140
|
+
case "logout":
|
|
141
|
+
return logout();
|
|
142
|
+
default:
|
|
143
|
+
console.log(HELP);
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
main().catch((err) => {
|
|
148
|
+
if (err instanceof ApiError) {
|
|
149
|
+
if (err.status === 401) {
|
|
150
|
+
console.error(`Your login expired or was revoked. Run: bipopush login (credentials: ${CREDENTIALS_FILE})`);
|
|
151
|
+
}
|
|
152
|
+
else if (err.body.error === "release_limit_reached") {
|
|
153
|
+
console.error(`Daily limit reached: ${err.message}`);
|
|
154
|
+
const usage = err.body.usage;
|
|
155
|
+
if (usage)
|
|
156
|
+
console.error(describeUsage(usage));
|
|
157
|
+
console.error("Upgrade your plan at https://app.bipopush.com");
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
console.error(`Error (${err.status}): ${err.message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
console.error(`Error: ${err.message}`);
|
|
165
|
+
}
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
});
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
}
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const PROJECT_FILE = "bipopush.json";
|
|
5
|
+
function readJson(file) {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function loadProjectConfig(root) {
|
|
14
|
+
return readJson(path.join(root, PROJECT_FILE));
|
|
15
|
+
}
|
|
16
|
+
export function saveProjectConfig(root, config) {
|
|
17
|
+
writeFileSync(path.join(root, PROJECT_FILE), `${JSON.stringify(config, null, 2)}\n`);
|
|
18
|
+
}
|
|
19
|
+
function pbxSetting(pbx, key) {
|
|
20
|
+
const values = [...pbx.matchAll(new RegExp(`${key} = "?([^";]+)"?;`, "g"))].map((m) => m[1].trim());
|
|
21
|
+
return values.find((v) => !v.includes("$(")) ?? values[0] ?? null;
|
|
22
|
+
}
|
|
23
|
+
function plistValue(file, keyPath) {
|
|
24
|
+
try {
|
|
25
|
+
return execFileSync("plutil", ["-extract", keyPath, "raw", "-o", "-", file], { stdio: ["ignore", "pipe", "ignore"] })
|
|
26
|
+
.toString()
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function readIos(root) {
|
|
34
|
+
const iosDir = path.join(root, "ios");
|
|
35
|
+
const empty = { info: { id: null, version: null, scheme: null }, plist: null };
|
|
36
|
+
if (!existsSync(iosDir))
|
|
37
|
+
return empty;
|
|
38
|
+
const proj = readdirSync(iosDir).find((f) => f.endsWith(".xcodeproj"));
|
|
39
|
+
if (!proj)
|
|
40
|
+
return empty;
|
|
41
|
+
const pbx = readFileSync(path.join(iosDir, proj, "project.pbxproj"), "utf8");
|
|
42
|
+
const productName = proj.replace(/\.xcodeproj$/, "");
|
|
43
|
+
let id = pbxSetting(pbx, "PRODUCT_BUNDLE_IDENTIFIER");
|
|
44
|
+
if (id?.includes("$(PRODUCT_NAME"))
|
|
45
|
+
id = id.replace(/\$\(PRODUCT_NAME[^)]*\)/, productName.replace(/[^A-Za-z0-9-]/g, "-"));
|
|
46
|
+
const plistRel = pbxSetting(pbx, "INFOPLIST_FILE");
|
|
47
|
+
const plist = plistRel ? path.join(iosDir, plistRel.replace("$(SRCROOT)/", "")) : null;
|
|
48
|
+
const scheme = plist ? plistValue(plist, "CFBundleURLTypes.0.CFBundleURLSchemes.0") : null;
|
|
49
|
+
// The version the app reports at runtime is CFBundleShortVersionString: a literal in Expo projects,
|
|
50
|
+
// "$(MARKETING_VERSION)" in bare React Native projects.
|
|
51
|
+
const plistVersion = plist ? plistValue(plist, "CFBundleShortVersionString") : null;
|
|
52
|
+
const version = plistVersion && !plistVersion.includes("$(") ? plistVersion : pbxSetting(pbx, "MARKETING_VERSION");
|
|
53
|
+
return { info: { id, version, scheme }, plist: plist && existsSync(plist) ? plist : null };
|
|
54
|
+
}
|
|
55
|
+
function androidManifestFile(root) {
|
|
56
|
+
const file = path.join(root, "android", "app", "src", "main", "AndroidManifest.xml");
|
|
57
|
+
return existsSync(file) ? file : null;
|
|
58
|
+
}
|
|
59
|
+
function readAndroid(root) {
|
|
60
|
+
const gradleFile = ["build.gradle", "build.gradle.kts"].map((f) => path.join(root, "android", "app", f)).find(existsSync);
|
|
61
|
+
if (!gradleFile)
|
|
62
|
+
return { id: null, version: null, scheme: null };
|
|
63
|
+
const gradle = readFileSync(gradleFile, "utf8");
|
|
64
|
+
const id = gradle.match(/applicationId\s*=?\s*["']([^"']+)["']/)?.[1] ?? null;
|
|
65
|
+
const version = gradle.match(/versionName\s*=?\s*["']([^"']+)["']/)?.[1] ?? null;
|
|
66
|
+
const manifestFile = path.join(root, "android", "app", "src", "main", "AndroidManifest.xml");
|
|
67
|
+
const manifest = existsSync(manifestFile) ? readFileSync(manifestFile, "utf8") : "";
|
|
68
|
+
const scheme = manifest.match(/<data[^>]*android:scheme="([^"]+)"/)?.[1] ?? null;
|
|
69
|
+
return { id, version, scheme };
|
|
70
|
+
}
|
|
71
|
+
/** Detects Expo vs bare React Native and reads native identifiers, versions and URL scheme. */
|
|
72
|
+
export function detectProject(root) {
|
|
73
|
+
const pkg = readJson(path.join(root, "package.json"));
|
|
74
|
+
if (!pkg)
|
|
75
|
+
throw new Error(`No package.json in ${root}`);
|
|
76
|
+
if (!pkg.dependencies?.["react-native"])
|
|
77
|
+
throw new Error("This is not a React Native project (react-native is not a dependency)");
|
|
78
|
+
const appJson = readJson(path.join(root, "app.json"));
|
|
79
|
+
const isExpo = Boolean(pkg.dependencies?.expo && appJson?.expo);
|
|
80
|
+
const ios = readIos(root);
|
|
81
|
+
const android = readAndroid(root);
|
|
82
|
+
const entryFile = pkg.main && existsSync(path.join(root, pkg.main)) ? pkg.main : "index.js";
|
|
83
|
+
if (isExpo && !ios.info.id) {
|
|
84
|
+
const expo = appJson.expo;
|
|
85
|
+
const scheme = Array.isArray(expo.scheme) ? expo.scheme[0] : (expo.scheme ?? null);
|
|
86
|
+
return {
|
|
87
|
+
root,
|
|
88
|
+
type: "expo",
|
|
89
|
+
name: expo.name ?? pkg.name ?? "app",
|
|
90
|
+
entryFile,
|
|
91
|
+
ios: { id: expo.ios?.bundleIdentifier ?? null, version: expo.ios?.version ?? expo.version ?? null, scheme },
|
|
92
|
+
android: { id: expo.android?.package ?? null, version: expo.version ?? null, scheme },
|
|
93
|
+
iosInfoPlist: null,
|
|
94
|
+
androidManifest: null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
root,
|
|
99
|
+
type: isExpo ? "expo" : "react-native",
|
|
100
|
+
name: appJson?.expo?.name ?? pkg.name ?? "app",
|
|
101
|
+
entryFile,
|
|
102
|
+
ios: ios.info,
|
|
103
|
+
android,
|
|
104
|
+
iosInfoPlist: ios.plist,
|
|
105
|
+
androidManifest: isExpo ? null : androidManifestFile(root),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Writes BipoPush keys as <meta-data> of <application> in AndroidManifest.xml (the Android module reads
|
|
110
|
+
* them like iOS reads Info.plist). Keys passed as null are removed.
|
|
111
|
+
*/
|
|
112
|
+
export function writeAndroidManifest(file, values) {
|
|
113
|
+
let xml = readFileSync(file, "utf8");
|
|
114
|
+
for (const [key, value] of Object.entries(values)) {
|
|
115
|
+
const existing = new RegExp(`\\s*<meta-data\\s+android:name="${key}"[^>]*/>`, "g");
|
|
116
|
+
xml = xml.replace(existing, "");
|
|
117
|
+
if (value === null)
|
|
118
|
+
continue;
|
|
119
|
+
xml = xml.replace(/(<application\b[^>]*>)/, (tag) => `${tag}\n <meta-data android:name="${key}" android:value="${value}" />`);
|
|
120
|
+
}
|
|
121
|
+
writeFileSync(file, xml);
|
|
122
|
+
}
|
|
123
|
+
/** Adds a VIEW intent-filter for `<scheme>://` deeplinks to the launcher activity. */
|
|
124
|
+
export function addAndroidUrlScheme(file, scheme) {
|
|
125
|
+
const xml = readFileSync(file, "utf8");
|
|
126
|
+
const filter = `
|
|
127
|
+
<intent-filter>
|
|
128
|
+
<action android:name="android.intent.action.VIEW" />
|
|
129
|
+
<category android:name="android.intent.category.DEFAULT" />
|
|
130
|
+
<category android:name="android.intent.category.BROWSABLE" />
|
|
131
|
+
<data android:scheme="${scheme}" />
|
|
132
|
+
</intent-filter>`;
|
|
133
|
+
const launcher = /(<category android:name="android\.intent\.category\.LAUNCHER"\s*\/>\s*<\/intent-filter>)/;
|
|
134
|
+
if (!launcher.test(xml))
|
|
135
|
+
throw new Error(`Could not find the launcher activity in ${file}; add the ${scheme}:// intent-filter by hand.`);
|
|
136
|
+
writeFileSync(file, xml.replace(launcher, (launcherFilter) => `${launcherFilter}${filter}`));
|
|
137
|
+
}
|
|
138
|
+
/** Writes BipoPush keys into the iOS Info.plist so the native module needs no JS configuration. Keys passed as null are removed. */
|
|
139
|
+
export function writeIosInfoPlist(plist, values) {
|
|
140
|
+
for (const [key, value] of Object.entries(values)) {
|
|
141
|
+
if (value !== null)
|
|
142
|
+
execFileSync("plutil", ["-replace", key, "-string", value, plist]);
|
|
143
|
+
else if (plistValue(plist, key) !== null)
|
|
144
|
+
execFileSync("plutil", ["-remove", key, plist]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Registers a URL scheme (CFBundleURLTypes) so `<scheme>://` deeplinks open the app. */
|
|
148
|
+
export function addIosUrlScheme(plist, scheme) {
|
|
149
|
+
const run = (cmd) => execFileSync("/usr/libexec/PlistBuddy", ["-c", cmd, plist], { stdio: ["ignore", "ignore", "ignore"] });
|
|
150
|
+
try {
|
|
151
|
+
run("Print :CFBundleURLTypes");
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
run("Add :CFBundleURLTypes array");
|
|
155
|
+
}
|
|
156
|
+
run("Add :CFBundleURLTypes:0 dict");
|
|
157
|
+
run("Add :CFBundleURLTypes:0:CFBundleURLSchemes array");
|
|
158
|
+
run(`Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string ${scheme}`);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Expo (continuous native generation): configure the BipoPush config plugin in app.json instead of
|
|
162
|
+
* editing generated native files. Also sets the URL scheme when one is given and none exists.
|
|
163
|
+
*/
|
|
164
|
+
export function writeExpoConfig(root, plugin, scheme) {
|
|
165
|
+
const file = path.join(root, "app.json");
|
|
166
|
+
const json = readJson(file);
|
|
167
|
+
if (!json?.expo)
|
|
168
|
+
throw new Error("app.json has no expo section");
|
|
169
|
+
const expo = json.expo;
|
|
170
|
+
const plugins = Array.isArray(expo.plugins) ? expo.plugins : [];
|
|
171
|
+
const name = "@bipopush/react-native";
|
|
172
|
+
const rest = plugins.filter((p) => (Array.isArray(p) ? p[0] : p) !== name);
|
|
173
|
+
expo.plugins = [...rest, [name, plugin]];
|
|
174
|
+
if (scheme && !expo.scheme)
|
|
175
|
+
expo.scheme = scheme;
|
|
176
|
+
writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`);
|
|
177
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bipopush/cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "BipoPush CLI — log in and publish releases",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "BipoPush <contact@bipopush.com> (https://bipopush.com)",
|
|
8
|
+
"homepage": "https://bipopush.com",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"email": "contact@bipopush.com"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react-native",
|
|
14
|
+
"expo",
|
|
15
|
+
"ota",
|
|
16
|
+
"over-the-air",
|
|
17
|
+
"code-push",
|
|
18
|
+
"cli"
|
|
19
|
+
],
|
|
20
|
+
"bin": {
|
|
21
|
+
"bipopush": "dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"test": "tsc && node --test test/*.test.mjs"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^26.6.3",
|
|
36
|
+
"typescript": "^6.0.3"
|
|
37
|
+
},
|
|
38
|
+
"optionalDependencies": {
|
|
39
|
+
"bsdiff-nodejs": "^2.2.0"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|