@bipopush/cli 0.3.0-beta.2 → 0.3.0-beta.4

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,363 +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, plan } = await request(`/apps/${config.appId}`, { token });
159
- // Same answer the API gives, before spending time on bundling and uploads.
160
- if (opts.rollout !== undefined && opts.rollout < 100 && plan?.gradualRollouts === false) {
161
- throw new Error("Gradual rollouts are on Plus and above. Release to everyone (no --rollout), or upgrade in the dashboard: Subscription.");
162
- }
163
- const wanted = opts.platform === "all" ? ["ios", "android"] : [opts.platform];
164
- const platforms = wanted.filter((p) => project[p].id && project[p].version);
165
- if (platforms.length === 0)
166
- throw new Error(`No native ${wanted.join("/")} project found to release for`);
167
- console.log(`Releasing ${app.name} → ${opts.channel} (${platforms.join(", ")})`);
168
- const packed = [];
169
- for (const platform of platforms) {
170
- const appVersion = project[platform].version;
171
- const base = await fetchBase(token, app.id, opts.channel, platform, appVersion);
172
- packed.push(await packPlatform({ project, platform, appVersion, entryFile: config.entryFile ?? project.entryFile, privateKey, base }));
173
- }
174
- packed.forEach(summary);
175
- if (opts.dryRun) {
176
- console.log("\nDry run: nothing uploaded.");
177
- return;
178
- }
179
- // Assets are stored once per app: upload only the hashes the server does not have.
180
- const allAssets = new Map();
181
- for (const a of packed)
182
- for (const x of a.assets)
183
- allAssets.set(x.sha256, { sha256: x.sha256, size: x.size, data: a.assetFiles.get(x.sha256) });
184
- if (allAssets.size > 0) {
185
- const { uploads } = await request(`/apps/${app.id}/assets`, {
186
- method: "POST",
187
- token,
188
- headers: { "content-type": "application/json" },
189
- body: JSON.stringify({ assets: [...allAssets.values()].map(({ sha256, size }) => ({ sha256, size })) }),
190
- });
191
- for (const u of uploads)
192
- await put(u.url, allAssets.get(u.sha256).data);
193
- const bytes = uploads.reduce((n, u) => n + allAssets.get(u.sha256).size, 0);
194
- console.log(`\nAssets: ${uploads.length} new uploaded (${kb(bytes)}), ${allAssets.size - uploads.length} already stored.`);
195
- }
196
- const created = await waitingForBurst(() => request(`/apps/${app.id}/releases`, {
197
- method: "POST",
198
- token,
199
- headers: { "content-type": "application/json" },
200
- body: JSON.stringify({
201
- channel: opts.channel,
202
- message: opts.message,
203
- mandatory: Boolean(opts.mandatory),
204
- ...(opts.rollout !== undefined && opts.rollout < 100 ? { rolloutPercent: opts.rollout } : {}),
205
- artifacts: packed.map((a) => ({
206
- platform: a.platform,
207
- appVersion: a.appVersion,
208
- ...(project[a.platform].build ? { nativeBuild: project[a.platform].build } : {}),
209
- sha256: a.sha256,
210
- signature: a.signature,
211
- bundleSize: a.bundleSize,
212
- fullSize: a.full.length,
213
- ...(a.patch ? { patch: { baseArtifactId: a.patch.baseArtifactId, size: a.patch.data.length } } : {}),
214
- ...(a.assets.length > 0 ? { assets: a.assets } : {}),
215
- })),
216
- }),
217
- }));
218
- for (const upload of created.uploads) {
219
- const a = packed.find((p) => p.platform === upload.platform);
220
- await put(upload.fullUrl, a.full);
221
- if (upload.patchUrl && a.patch)
222
- await put(upload.patchUrl, a.patch.data);
223
- }
224
- const { release: published, usage } = await waitingForBurst(() => request(`/releases/${created.releaseId}/publish`, { method: "POST", token }));
225
- const labels = published.artifacts.map((a) => `${a.platform === "ios" ? "iOS" : "Android"} ${a.appVersion} v${a.version}`);
226
- const rolling = opts.rollout !== undefined && opts.rollout < 100 ? `, to ${opts.rollout}% of devices` : "";
227
- console.log(`\nPublished ${labels.join(", ")} to ${opts.channel}${opts.mandatory ? " (mandatory)" : ""}${rolling}.`);
228
- if (rolling)
229
- console.log(`Raise it later with: bipopush rollout <percent> -c ${opts.channel} (or 100 to finish)`);
230
- console.log(`${usage.used} release${usage.used === 1 ? "" : "s"} of this app in the last 24h.`);
231
- }
232
- /** Waits out a release pause (going over the plan's releases per 10 minutes) instead of failing, e.g. in CI. */
233
- async function waitingForBurst(call) {
234
- for (;;) {
235
- try {
236
- return await call();
237
- }
238
- catch (err) {
239
- if (!(err instanceof ApiError) || err.body.error !== "release_too_soon")
240
- throw err;
241
- const wait = Number(err.body.retryAfterSeconds);
242
- if (!(wait > 0 && wait <= 31 * 60))
243
- throw err;
244
- console.log(`${err.message} Waiting ${wait}s…`);
245
- await new Promise((r) => setTimeout(r, wait * 1000));
246
- }
247
- }
248
- }
249
- export async function memberships(token) {
250
- return (await request("/me", { token })).organizations;
251
- }
252
- /** The organization new apps go to: --org (id, slug or name), or the only one the user can create apps in. */
253
- async function pickOrganization(token, ref) {
254
- const orgs = (await memberships(token)).filter((o) => o.role.permissions.includes("app:create"));
255
- if (ref) {
256
- const wanted = ref.toLowerCase();
257
- const match = orgs.find((o) => [o.id, o.slug.current, o.slug.hash, o.slug.custom, o.name.toLowerCase()].includes(wanted));
258
- if (!match)
259
- throw new Error(`No organization "${ref}" where you can create apps. Run: bipopush whoami`);
260
- return match;
261
- }
262
- if (orgs.length === 1)
263
- return orgs[0];
264
- if (orgs.length === 0)
265
- throw new Error("Your role can't create apps in any organization. Ask an owner or admin.");
266
- const list = orgs.map((o) => ` --org ${o.slug.current} ${o.name}`).join("\n");
267
- throw new Error(`You belong to several organizations. Pick one:\n${list}`);
268
- }
269
- export async function listApps(token) {
270
- const { apps, organizations } = await request("/apps", { token });
271
- if (apps.length === 0)
272
- console.log("No apps yet. Run: bipopush init");
273
- for (const org of organizations) {
274
- const own = apps.filter((a) => a.organization.id === org.id);
275
- const limit = org.maxApps === null ? "unlimited" : `${own.length}/${org.maxApps}`;
276
- console.log(`\n${org.name} (${org.plan}, ${limit} apps)`);
277
- for (const a of own) {
278
- console.log(` ${a.name} ${a.id}\n key ${a.appKey} · ${a.mau} MAU · ${a.usage.used}/${a.usage.limit} releases today`);
279
- }
280
- }
281
- }
282
- /** The newest live version per platform and native version in a channel (or the given --number). */
283
- async function newestLive(token, opts) {
284
- const config = loadProjectConfig(opts.cwd);
285
- if (!config)
286
- throw new Error(`No ${PROJECT_FILE} here. Run: bipopush init`);
287
- const { releases } = await request(`/apps/${config.appId}/releases`, { token });
288
- const picked = new Map();
289
- for (const a of releases.filter((r) => r.channel === opts.channel).flatMap((r) => r.artifacts)) {
290
- if (a.disabled || a.fileDeleted || (opts.platform !== "all" && a.platform !== opts.platform))
291
- continue;
292
- if (opts.version !== undefined && a.version !== opts.version)
293
- continue;
294
- const key = `${a.platform}/${a.appVersion}`;
295
- if (!picked.has(key) || picked.get(key).version < a.version)
296
- picked.set(key, a);
297
- }
298
- if (picked.size === 0)
299
- throw new Error(`No live ${opts.version ? `v${opts.version} ` : ""}version in ${opts.channel}`);
300
- return { appId: config.appId, picked: [...picked.values()] };
301
- }
302
- const label = (a) => `${a.platform === "ios" ? "iOS" : "Android"} ${a.appVersion} v${a.version}`;
303
- function patchArtifact(token, appId, artifactId, body) {
304
- return request(`/apps/${appId}/artifacts/${artifactId}`, { method: "PATCH", token, headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
305
- }
306
- /** Changes how many devices get the newest version (100 finishes the rollout). */
307
- export async function rollout(token, opts) {
308
- const { appId, picked } = await newestLive(token, opts);
309
- for (const a of picked) {
310
- await patchArtifact(token, appId, a.id, { rolloutPercent: opts.percent });
311
- console.log(`${label(a)} on ${opts.channel}: ${a.rolloutPercent}% → ${opts.percent}% of devices${opts.percent === 100 ? " (rollout complete)" : ""}`);
312
- }
313
- }
314
- /** Disables the newest version of a channel (or --number): devices get the newest enabled one. */
315
- export async function disable(token, opts) {
316
- const { appId, picked } = await newestLive(token, opts);
317
- for (const a of picked) {
318
- await patchArtifact(token, appId, a.id, { disabled: true });
319
- console.log(`Disabled ${label(a)} on ${opts.channel}. Devices get the newest enabled version on their next check.`);
320
- }
321
- }
322
- /** Marks the newest version mandatory (or optional) and/or changes its rollout, like CodePush's patch. */
323
- export async function patchRelease(token, opts) {
324
- const { appId, picked } = await newestLive(token, opts);
325
- for (const a of picked) {
326
- if (opts.mandatory !== undefined)
327
- await patchArtifact(token, appId, a.id, { mandatory: opts.mandatory });
328
- if (opts.percent !== undefined)
329
- await patchArtifact(token, appId, a.id, { rolloutPercent: opts.percent });
330
- console.log(`Updated ${label(a)} on ${opts.channel}.`);
331
- }
332
- }
333
- /**
334
- * Re-publishes the newest live version of a channel (or a given --version) in another channel,
335
- * e.g. staging → production after testing. Same bundle and signature; does not use the daily quota.
336
- */
337
- export async function promote(token, opts) {
338
- const config = loadProjectConfig(opts.cwd);
339
- if (!config)
340
- throw new Error(`No ${PROJECT_FILE} here. Run: bipopush init`);
341
- if (opts.from === opts.to)
342
- throw new Error("--from and --to must be different channels");
343
- const { releases } = await request(`/apps/${config.appId}/releases`, { token });
344
- const candidates = releases
345
- .filter((r) => r.channel === opts.from)
346
- .flatMap((r) => r.artifacts)
347
- .filter((a) => !a.disabled && !a.fileDeleted && (opts.platform === "all" || a.platform === opts.platform));
348
- const picked = new Map();
349
- for (const a of candidates) {
350
- if (opts.version !== undefined && a.version !== opts.version)
351
- continue;
352
- const key = `${a.platform}/${a.appVersion}`;
353
- if (!picked.has(key) || picked.get(key).version < a.version)
354
- picked.set(key, a);
355
- }
356
- if (picked.size === 0)
357
- throw new Error(`No live ${opts.version ? `v${opts.version} ` : ""}version in ${opts.from} to promote`);
358
- for (const a of picked.values()) {
359
- 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 }) });
360
- const label = `${a.platform === "ios" ? "iOS" : "Android"} ${a.appVersion}`;
361
- console.log(`Promoted ${label} ${opts.from} v${a.version} → ${opts.to} v${r.artifacts[0].version}`);
362
- }
363
- }
package/dist/compat.js DELETED
@@ -1,96 +0,0 @@
1
- import { parseArgs } from "node:util";
2
- /** CodePush deployments are "Staging" and "Production"; BipoPush channels are lower-case. */
3
- const channelOf = (deployment) => deployment.trim().toLowerCase();
4
- export function percent(value, flag) {
5
- if (value === undefined)
6
- return undefined;
7
- const n = Number(value.replace(/%$/, ""));
8
- if (!Number.isInteger(n) || n < 1 || n > 100)
9
- throw new Error(`${flag} must be a whole number from 1 to 100`);
10
- return n;
11
- }
12
- const platformOf = (value) => {
13
- const p = (value ?? "all").toLowerCase();
14
- if (p !== "ios" && p !== "android" && p !== "all")
15
- throw new Error("Platform must be ios, android or all");
16
- return p;
17
- };
18
- /** Returns the mapped command, or null when `args` isn't a CodePush / EAS command line. */
19
- export function parseCompat(argv) {
20
- const args = argv[0] === "codepush" ? argv.slice(1) : argv;
21
- const codepush = argv[0] === "codepush";
22
- const command = args[0];
23
- if (command === "release-react") {
24
- const { positionals, values } = parseArgs({
25
- args: args.slice(1),
26
- allowPositionals: true,
27
- strict: false,
28
- options: {
29
- deploymentName: { type: "string", short: "d" },
30
- description: { type: "string" },
31
- des: { type: "string" },
32
- mandatory: { type: "boolean", short: "m" },
33
- rollout: { type: "string", short: "r" },
34
- app: { type: "string", short: "a" },
35
- targetBinaryVersion: { type: "string", short: "t" },
36
- },
37
- });
38
- // code-push release-react <app> <platform>; appcenter codepush release-react -a owner/app (platform from the project).
39
- const platform = codepush ? positionals[1] ?? positionals[0] : positionals[1];
40
- return {
41
- kind: "release",
42
- platform: platformOf(platform === undefined || /\//.test(platform) ? undefined : platform),
43
- channel: channelOf(typeof values.deploymentName === "string" ? values.deploymentName : "Staging"),
44
- message: (typeof values.description === "string" ? values.description : undefined) ?? (typeof values.des === "string" ? values.des : undefined),
45
- mandatory: values.mandatory === true,
46
- rollout: percent(typeof values.rollout === "string" ? values.rollout : undefined, "--rollout"),
47
- };
48
- }
49
- if (command === "promote" || command === "rollback" || command === "patch") {
50
- const { positionals, values } = parseArgs({
51
- args: args.slice(1),
52
- allowPositionals: true,
53
- strict: false,
54
- options: { mandatory: { type: "string", short: "m" }, rollout: { type: "string", short: "r" }, app: { type: "string", short: "a" } },
55
- });
56
- // BipoPush's own promote / rollback use flags; the CodePush forms are positional (<app> <deployment>…).
57
- const names = typeof values.app === "string" ? positionals : positionals.slice(1);
58
- if (command === "promote" && names.length >= 2)
59
- return { kind: "promote", from: channelOf(names[0]), to: channelOf(names[1]) };
60
- if (command === "rollback" && names.length >= 1)
61
- return { kind: "rollback", channel: channelOf(names[0]) };
62
- if (command === "patch" && names.length >= 1) {
63
- const m = typeof values.mandatory === "string" ? values.mandatory : undefined;
64
- return {
65
- kind: "patch",
66
- channel: channelOf(names[0]),
67
- mandatory: m === undefined ? undefined : m !== "false",
68
- rollout: percent(typeof values.rollout === "string" ? values.rollout : undefined, "--rollout"),
69
- };
70
- }
71
- return null;
72
- }
73
- if (command === "update" || command === "update:edit") {
74
- const { values } = parseArgs({
75
- args: args.slice(1),
76
- strict: false,
77
- options: {
78
- channel: { type: "string" },
79
- branch: { type: "string" },
80
- message: { type: "string", short: "m" },
81
- platform: { type: "string", short: "p" },
82
- "rollout-percentage": { type: "string" },
83
- },
84
- });
85
- const str = (v) => (typeof v === "string" ? v : undefined);
86
- const channel = channelOf(str(values.channel) ?? str(values.branch) ?? "production");
87
- const rollout = percent(str(values["rollout-percentage"]), "--rollout-percentage");
88
- if (command === "update:edit") {
89
- if (rollout === undefined)
90
- throw new Error("update:edit needs --rollout-percentage");
91
- return { kind: "rollout", channel, platform: platformOf(str(values.platform)), percent: rollout };
92
- }
93
- return { kind: "release", platform: platformOf(str(values.platform)), channel, message: str(values.message), mandatory: false, rollout };
94
- }
95
- return null;
96
- }
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
- }