@novacraft-engineering/pulsar-cli 1.2.1 → 1.2.3
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/README.md +32 -6
- package/package.json +13 -9
- package/pulsar.js +105 -15
package/README.md
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
# @novacraft-engineering/pulsar-cli
|
|
2
2
|
|
|
3
|
-
Release CLI for [Pulsar](https://
|
|
3
|
+
Release CLI for [Pulsar](https://usepulsar.dev) — the free, self-hostable **CodePush alternative** for React Native. Bundle your JavaScript, hash it exactly the way the on-device client does, and ship it over-the-air to live apps in seconds. No app-store review.
|
|
4
|
+
|
|
5
|
+
Microsoft App Center CodePush was retired in March 2025. Pulsar speaks the same CodePush protocol, so migrating is a deployment key and a server URL — not a rewrite. See [usepulsar.dev/codepush-alternative](https://usepulsar.dev/codepush-alternative).
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
10
|
npm i -g @novacraft-engineering/pulsar-cli
|
|
9
11
|
# or run without installing:
|
|
10
|
-
npx @novacraft-engineering/pulsar-cli login https://api.
|
|
12
|
+
npx @novacraft-engineering/pulsar-cli login https://api.usepulsar.dev
|
|
11
13
|
```
|
|
12
14
|
|
|
15
|
+
`<apiUrl>` is whatever Pulsar server you release against. `api.usepulsar.dev`
|
|
16
|
+
is the hosted one; point it at your own if you self-host. The CLI talks to the
|
|
17
|
+
Pulsar API and nothing else, so it has no host compiled into it.
|
|
18
|
+
|
|
13
19
|
## Commands
|
|
14
20
|
|
|
15
21
|
```bash
|
|
16
|
-
pulsar login <apiUrl> # authenticate, saved to ~/.pulsar/config.json
|
|
22
|
+
pulsar login <apiUrl> # authenticate (API key or browser), saved to ~/.pulsar/config.json
|
|
23
|
+
# --api-key paste a key from Settings → API keys
|
|
24
|
+
# --browser sign in via the dashboard, no key to manage
|
|
17
25
|
pulsar apps # list your apps and deployment keys
|
|
18
26
|
|
|
19
27
|
pulsar release <app> <deployment> --platform ios|android --target <ver> \
|
|
@@ -35,9 +43,6 @@ pulsar patch <app> <deployment> <label> [--rollout N] [--mandatory] [--disable]
|
|
|
35
43
|
- `--disabled` — upload the bundle but don't serve it (flip on later with `pulsar patch … --enable`).
|
|
36
44
|
- `--target <range>` — semver range of native binary versions the release applies to.
|
|
37
45
|
|
|
38
|
-
```bash
|
|
39
|
-
```
|
|
40
|
-
|
|
41
46
|
## How a release works
|
|
42
47
|
|
|
43
48
|
`release` runs the React Native bundler, computes the **CodePush package hash** (the exact
|
|
@@ -47,6 +52,27 @@ then downloads it in the background and swaps it in on next launch.
|
|
|
47
52
|
|
|
48
53
|
Auth is read from `PULSAR_API_URL` / `PULSAR_TOKEN` env, or `~/.pulsar/config.json` (written by `pulsar login`). Nothing is committed.
|
|
49
54
|
|
|
55
|
+
## Use it from CI
|
|
56
|
+
|
|
57
|
+
Both env vars are all a GitHub Actions / GitLab CI job needs:
|
|
58
|
+
|
|
59
|
+
```yaml
|
|
60
|
+
- run: npm i -g @novacraft-engineering/pulsar-cli
|
|
61
|
+
- run: pulsar release MyApp Production --platform ios --target 1.4.0 --rollout 25
|
|
62
|
+
env:
|
|
63
|
+
PULSAR_API_URL: https://api.usepulsar.dev
|
|
64
|
+
PULSAR_TOKEN: ${{ secrets.PULSAR_TOKEN }}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or connect the repo in the dashboard and fire a release for any commit without leaving the browser.
|
|
68
|
+
|
|
69
|
+
## Related
|
|
70
|
+
|
|
71
|
+
- [`@novacraft-engineering/pulsar-code-push`](https://github.com/Novacraft-Africa/pulsar-code-push) — the React Native client this CLI ships to
|
|
72
|
+
- [Pulsar vs CodePush vs RevoPush](https://usepulsar.dev/blog/codepush-vs-revopush-vs-pulsar)
|
|
73
|
+
- [How React Native OTA updates actually work](https://usepulsar.dev/blog/how-react-native-ota-updates-work)
|
|
74
|
+
- [Staged rollouts and instant rollback](https://usepulsar.dev/blog/staged-rollouts-instant-rollback-react-native)
|
|
75
|
+
|
|
50
76
|
## License
|
|
51
77
|
|
|
52
78
|
MIT © Novacraft Engineering
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/pulsar-cli",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "Pulsar OTA release CLI
|
|
3
|
+
"version": "1.2.3",
|
|
4
|
+
"description": "Pulsar OTA release CLI — bundle, hash, and ship React Native updates over-the-air.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"pulsar": "./pulsar.js"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"type": "git",
|
|
17
17
|
"url": "git+https://github.com/Novacraft-Africa/pulsar-cli.git"
|
|
18
18
|
},
|
|
19
|
-
"homepage": "https://
|
|
19
|
+
"homepage": "https://usepulsar.dev",
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"author": "Novacraft Engineering",
|
|
22
22
|
"publishConfig": {
|
|
@@ -26,15 +26,19 @@
|
|
|
26
26
|
"node": ">=18"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "node test/hash.test.js && node test/security.test.js"
|
|
29
|
+
"test": "node test/hash.test.js && node test/security.test.js && node test/signing.test.js"
|
|
30
30
|
},
|
|
31
31
|
"keywords": [
|
|
32
|
-
"
|
|
33
|
-
"ota",
|
|
32
|
+
"cli",
|
|
34
33
|
"code-push",
|
|
34
|
+
"codepush-alternative",
|
|
35
|
+
"hot-update",
|
|
36
|
+
"ota",
|
|
37
|
+
"ota-updates",
|
|
38
|
+
"over-the-air",
|
|
35
39
|
"pulsar",
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
40
|
+
"react-native",
|
|
41
|
+
"react-native-ota",
|
|
42
|
+
"release"
|
|
39
43
|
]
|
|
40
44
|
}
|
package/pulsar.js
CHANGED
|
@@ -9,7 +9,10 @@ import crypto from 'node:crypto';
|
|
|
9
9
|
import {computePackageHash, fileHashes} from './hash.js';
|
|
10
10
|
|
|
11
11
|
// Code signing: sign the package hash into an RS256 JWT the client verifies against its
|
|
12
|
-
// CodePushPublicKey (Info.plist).
|
|
12
|
+
// CodePushPublicKey (Info.plist). Both native SDKs read it from <update>/CodePush/.codepushrelease
|
|
13
|
+
// and nowhere else; a signature anywhere else counts as no signature.
|
|
14
|
+
const SIGNATURE_PATH = 'CodePush/.codepushrelease';
|
|
15
|
+
|
|
13
16
|
function signCodePushJWT(privateKeyPem, contentHash) {
|
|
14
17
|
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
15
18
|
const signingInput = `${encode({alg: 'RS256', typ: 'JWT'})}.${encode({contentHash})}`;
|
|
@@ -246,8 +249,11 @@ async function buildDiffPackage(baseZip, newDir) {
|
|
|
246
249
|
fs.writeFileSync(path.join(diffDir, 'hotcodepush.json'), JSON.stringify({deletedFiles}));
|
|
247
250
|
// The signature isn't part of the content hash, so it's excluded above — but the patched bundle
|
|
248
251
|
// still needs it to verify, so carry it into the diff when the release is signed.
|
|
249
|
-
const signature = path.join(newDir,
|
|
250
|
-
if (fs.existsSync(signature))
|
|
252
|
+
const signature = path.join(newDir, SIGNATURE_PATH);
|
|
253
|
+
if (fs.existsSync(signature)) {
|
|
254
|
+
fs.mkdirSync(path.dirname(path.join(diffDir, SIGNATURE_PATH)), {recursive: true});
|
|
255
|
+
fs.copyFileSync(signature, path.join(diffDir, SIGNATURE_PATH));
|
|
256
|
+
}
|
|
251
257
|
return diffDir;
|
|
252
258
|
}
|
|
253
259
|
|
|
@@ -257,6 +263,35 @@ async function fetchBlob(apiUrl, appId, fileName) {
|
|
|
257
263
|
return Buffer.from(await response.arrayBuffer());
|
|
258
264
|
}
|
|
259
265
|
|
|
266
|
+
const splitList = (value, separator) => String(value || '').split(separator).map((entry) => entry.trim()).filter(Boolean);
|
|
267
|
+
|
|
268
|
+
// --schedule takes either an absolute instant ("2026-09-20T09:00:00Z", "2026-09-20 09:00") or a
|
|
269
|
+
// relative offset from now ("+90m", "+6h", "+3d"). Resolved here so the API only sees ISO.
|
|
270
|
+
function parseSchedule(value) {
|
|
271
|
+
if (value === undefined) return null;
|
|
272
|
+
const raw = String(value).trim();
|
|
273
|
+
const relative = raw.match(/^\+(\d+(?:\.\d+)?)\s*(m|h|d)$/i);
|
|
274
|
+
if (relative) {
|
|
275
|
+
const unit = {m: 60000, h: 3600000, d: 86400000}[relative[2].toLowerCase()];
|
|
276
|
+
return new Date(Date.now() + Number(relative[1]) * unit).toISOString();
|
|
277
|
+
}
|
|
278
|
+
const at = Date.parse(raw.includes('T') || !raw.includes(' ') ? raw : raw.replace(' ', 'T'));
|
|
279
|
+
if (!Number.isFinite(at)) die(`Invalid --schedule "${raw}". Use an ISO instant or a relative offset like +6h.`);
|
|
280
|
+
if (at <= Date.now()) die(`--schedule "${raw}" is in the past.`);
|
|
281
|
+
return new Date(at).toISOString();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --ramp takes "<to>@<hours>" ("100@48") or just "<hours>" (ramps to 100).
|
|
285
|
+
function parseRamp(value) {
|
|
286
|
+
if (value === undefined) return null;
|
|
287
|
+
const raw = String(value).trim();
|
|
288
|
+
const match = raw.match(/^(?:(\d{1,3})@)?(\d+(?:\.\d+)?)$/);
|
|
289
|
+
if (!match) die(`Invalid --ramp "${raw}". Use <toPercent>@<hours>, e.g. 100@48.`);
|
|
290
|
+
const to = match[1] === undefined ? 100 : Number(match[1]);
|
|
291
|
+
if (to > 100) die('--ramp target cannot exceed 100.');
|
|
292
|
+
return {to, hours: Number(match[2])};
|
|
293
|
+
}
|
|
294
|
+
|
|
260
295
|
function getFlag(args, name, fallback) {
|
|
261
296
|
const index = args.indexOf(`--${name}`);
|
|
262
297
|
return index >= 0 ? args[index + 1] : fallback;
|
|
@@ -268,17 +303,31 @@ async function cmdLogin(args) {
|
|
|
268
303
|
|
|
269
304
|
let mode;
|
|
270
305
|
if (args.includes('--browser')) mode = 'browser';
|
|
306
|
+
else if (args.includes('--api-key') || args.includes('--apikey')) mode = 'apikey';
|
|
271
307
|
else if (args.includes('--terminal')) mode = 'terminal';
|
|
272
308
|
else {
|
|
273
|
-
const answer = (await prompt('Sign in with [
|
|
274
|
-
mode = answer.startsWith('
|
|
309
|
+
const answer = (await prompt('Sign in with [a]pi key or [b]rowser? (a/b): ')).toLowerCase();
|
|
310
|
+
mode = answer.startsWith('a') ? 'apikey' : 'browser';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (mode === 'apikey') {
|
|
314
|
+
const token = (await prompt('Paste your Pulsar API key: ', true)).trim();
|
|
315
|
+
if (!token) die('No API key provided.');
|
|
316
|
+
saveConfig({apiUrl, token});
|
|
317
|
+
const me = await api('/auth/me').catch(() => null);
|
|
318
|
+
if (!me?.user?.email) {
|
|
319
|
+
saveConfig({apiUrl, token: undefined});
|
|
320
|
+
die('That API key was rejected. Create one in the dashboard → Settings → API keys.');
|
|
321
|
+
}
|
|
322
|
+
console.log(`✓ logged in as ${me.user.email} (api key)`);
|
|
323
|
+
return;
|
|
275
324
|
}
|
|
276
325
|
|
|
277
326
|
if (mode === 'browser') {
|
|
278
327
|
const token = await browserLogin(apiUrl);
|
|
279
328
|
saveConfig({apiUrl, token});
|
|
280
329
|
const me = await api('/auth/me').catch(() => null);
|
|
281
|
-
console.log(`✓ logged in${me?.email ? ` as ${me.email}` : ''} (browser)`);
|
|
330
|
+
console.log(`✓ logged in${me?.user?.email ? ` as ${me.user.email}` : ''} (browser)`);
|
|
282
331
|
return;
|
|
283
332
|
}
|
|
284
333
|
|
|
@@ -311,6 +360,8 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
311
360
|
} catch {}
|
|
312
361
|
}
|
|
313
362
|
|
|
363
|
+
const startsAt = parseSchedule(getFlag(args, 'schedule'));
|
|
364
|
+
const ramp = parseRamp(getFlag(args, 'ramp'));
|
|
314
365
|
const release = await api(`/apps/${app.id}/deployments/${deployment}/release`, {
|
|
315
366
|
method: 'POST',
|
|
316
367
|
raw: true,
|
|
@@ -324,11 +375,25 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
324
375
|
'x-disabled': String(args.includes('--disabled')),
|
|
325
376
|
'x-rollout': String(getFlag(args, 'rollout', '100')),
|
|
326
377
|
'x-client-ids': getFlag(args, 'devices', ''),
|
|
378
|
+
// JSON-encoded: iOS model ids contain a comma ("iPhone14,3"), so separate models with ';'.
|
|
379
|
+
'x-target-devices': JSON.stringify(splitList(getFlag(args, 'device-models', ''), ';')),
|
|
380
|
+
'x-target-users': JSON.stringify(splitList(getFlag(args, 'users', ''), ',')),
|
|
381
|
+
'x-target-from': JSON.stringify(splitList(getFlag(args, 'from', ''), ',')),
|
|
327
382
|
'x-description': encodeURIComponent(getFlag(args, 'description', '')),
|
|
383
|
+
...(startsAt ? {'x-starts-at': startsAt} : {}),
|
|
384
|
+
...(ramp ? {'x-rollout-to': String(ramp.to), 'x-rollout-hours': String(ramp.hours)} : {}),
|
|
328
385
|
},
|
|
329
386
|
});
|
|
330
|
-
const
|
|
387
|
+
const scopes = [
|
|
388
|
+
release.clientIds?.length ? `${release.clientIds.length} device id(s)` : null,
|
|
389
|
+
release.targetDevices?.length ? `${release.targetDevices.length} device model(s)` : null,
|
|
390
|
+
release.targetUserIds?.length ? `${release.targetUserIds.length} user(s)` : null,
|
|
391
|
+
release.targetFromLabels?.length ? `devices on ${release.targetFromLabels.join('/')}` : null,
|
|
392
|
+
].filter(Boolean);
|
|
393
|
+
const reach = scopes.length ? scopes.join(' + ') : `${release.rollout}% of devices`;
|
|
331
394
|
console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}`);
|
|
395
|
+
if (release.startsAt) console.log(` scheduled — goes live ${new Date(release.startsAt).toLocaleString()}`);
|
|
396
|
+
if (release.rolloutRamp) console.log(` gradual rollout — ${release.rollout}% to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h`);
|
|
332
397
|
|
|
333
398
|
if (prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
|
|
334
399
|
try {
|
|
@@ -358,9 +423,20 @@ function buildOnce(args) {
|
|
|
358
423
|
const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
|
|
359
424
|
bundle(platform, contentsDir);
|
|
360
425
|
const packageHash = computePackageHash(contentsDir);
|
|
426
|
+
// CI secrets arrive as environment variables holding the key itself, not as files, so the key can
|
|
427
|
+
// come from PULSAR_SIGNING_KEY as well as --private-key <file>. An unset or empty secret means
|
|
428
|
+
// "don't sign", exactly as before.
|
|
361
429
|
const privateKeyPath = getFlag(args, 'private-key');
|
|
362
|
-
|
|
363
|
-
|
|
430
|
+
const signingKey = privateKeyPath ? fs.readFileSync(privateKeyPath, 'utf8') : (process.env.PULSAR_SIGNING_KEY || '').trim();
|
|
431
|
+
if (signingKey) {
|
|
432
|
+
let signature;
|
|
433
|
+
try {
|
|
434
|
+
signature = signCodePushJWT(signingKey, packageHash);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
die(`could not sign with the provided key (${error.message}) — nothing was uploaded`);
|
|
437
|
+
}
|
|
438
|
+
fs.mkdirSync(path.join(contentsDir, 'CodePush'), {recursive: true});
|
|
439
|
+
fs.writeFileSync(path.join(contentsDir, SIGNATURE_PATH), signature);
|
|
364
440
|
console.log('• signed release — devices with CodePushPublicKey set will verify it');
|
|
365
441
|
}
|
|
366
442
|
const zip = fs.readFileSync(zipContents(contentsDir));
|
|
@@ -370,7 +446,7 @@ function buildOnce(args) {
|
|
|
370
446
|
|
|
371
447
|
async function cmdRelease(args) {
|
|
372
448
|
const [nameOrId, deployment = 'Staging'] = args;
|
|
373
|
-
if (!nameOrId) die('Usage: pulsar release <app> <Staging|Production> --platform ios|android --target <ver> [--mandatory] [--disabled] [--rollout N] [--devices id1,id2,...] [--private-key key.pem] [--no-diff] [--description "..."]');
|
|
449
|
+
if (!nameOrId) die('Usage: pulsar release <app> <Staging|Production> --platform ios|android --target <ver> [--mandatory] [--disabled] [--rollout N] [--schedule +6h|ISO] [--ramp 100@48] [--from v6,ota,binary] [--devices id1,id2,...] [--device-models model1;model2] [--users u1,u2] [--private-key key.pem] [--no-diff] [--description "..."]');
|
|
374
450
|
if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
|
|
375
451
|
const app = await resolveApp(nameOrId, getFlag(args, 'platform', 'ios'));
|
|
376
452
|
if (app.os !== getFlag(args, 'platform', 'ios')) {
|
|
@@ -454,13 +530,17 @@ async function cmdHistory(args) {
|
|
|
454
530
|
if (!releases.length) return console.log('(no releases)');
|
|
455
531
|
for (const release of [...releases].reverse()) {
|
|
456
532
|
const flags = `${release.isMandatory ? ' [mandatory]' : ''}${release.isDisabled ? ' [disabled]' : ''}`;
|
|
457
|
-
|
|
533
|
+
const rollout = release.currentRollout ?? release.rollout;
|
|
534
|
+
const state = release.state && release.state !== 'live' ? ` [${release.state}]` : '';
|
|
535
|
+
const when = release.startsAt ? ` starts ${new Date(release.startsAt).toLocaleString()}` : '';
|
|
536
|
+
const ramping = release.rolloutRamp ? ` ->${release.rolloutRamp.to}%/${release.rolloutRamp.hours}h` : '';
|
|
537
|
+
console.log(` ${release.label.padEnd(5)} target ${String(release.appVersion).padEnd(10)} rollout ${String(rollout).padStart(3)}%${ramping}${flags}${state}${when} ${release.description || ''}`);
|
|
458
538
|
}
|
|
459
539
|
}
|
|
460
540
|
|
|
461
541
|
async function cmdPatch(args) {
|
|
462
542
|
const [nameOrId, deployment, label] = args;
|
|
463
|
-
if (!label) die('Usage: pulsar patch <app> <deployment> <label> [--rollout N] [--mandatory] [--disable] [--enable] [--devices id1,id2,...]');
|
|
543
|
+
if (!label) die('Usage: pulsar patch <app> <deployment> <label> [--rollout N] [--schedule +6h|ISO|none] [--ramp 100@48|none] [--from v6,ota,binary] [--mandatory] [--disable] [--enable] [--devices id1,id2,...] [--device-models model1;model2] [--users u1,u2]');
|
|
464
544
|
const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
|
|
465
545
|
const body = {};
|
|
466
546
|
const rollout = getFlag(args, 'rollout');
|
|
@@ -469,9 +549,19 @@ async function cmdPatch(args) {
|
|
|
469
549
|
if (args.includes('--disable')) body.isDisabled = true;
|
|
470
550
|
if (args.includes('--enable')) body.isDisabled = false;
|
|
471
551
|
const devices = getFlag(args, 'devices');
|
|
472
|
-
if (devices !== undefined) body.clientIds = devices
|
|
552
|
+
if (devices !== undefined) body.clientIds = splitList(devices, ',');
|
|
553
|
+
const deviceModels = getFlag(args, 'device-models');
|
|
554
|
+
if (deviceModels !== undefined) body.targetDevices = splitList(deviceModels, ';');
|
|
555
|
+
const users = getFlag(args, 'users');
|
|
556
|
+
if (users !== undefined) body.targetUserIds = splitList(users, ',');
|
|
557
|
+
const from = getFlag(args, 'from');
|
|
558
|
+
if (from !== undefined) body.targetFromLabels = from === 'none' ? [] : splitList(from, ',');
|
|
559
|
+
const schedule = getFlag(args, 'schedule');
|
|
560
|
+
if (schedule !== undefined) body.startsAt = schedule === 'none' ? null : parseSchedule(schedule);
|
|
561
|
+
const ramp = getFlag(args, 'ramp');
|
|
562
|
+
if (ramp !== undefined) body.rolloutRamp = ramp === 'none' ? null : parseRamp(ramp);
|
|
473
563
|
const release = await api(`/apps/${app.id}/deployments/${deployment}/releases/${label}`, {method: 'PATCH', body});
|
|
474
|
-
console.log(`✓ patched ${label}: rollout ${release.rollout}%${release.isMandatory ? ' mandatory' : ''}${release.isDisabled ? ' disabled' : ''}`);
|
|
564
|
+
console.log(`✓ patched ${label}: rollout ${release.rollout}%${release.rolloutRamp ? ` to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h` : ''}${release.startsAt ? ` starting ${new Date(release.startsAt).toLocaleString()}` : ''}${release.isMandatory ? ' mandatory' : ''}${release.isDisabled ? ' disabled' : ''}`);
|
|
475
565
|
}
|
|
476
566
|
|
|
477
567
|
// Deployment keys for a whole React Native project (its -Android and -iOS apps), shaped as the
|
|
@@ -521,7 +611,7 @@ async function cmdKeys(args) {
|
|
|
521
611
|
console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
|
|
522
612
|
}
|
|
523
613
|
|
|
524
|
-
export {saveConfig, browserLogin};
|
|
614
|
+
export {saveConfig, browserLogin, signCodePushJWT, SIGNATURE_PATH};
|
|
525
615
|
|
|
526
616
|
// Only dispatch when run as the CLI — importing the module (e.g. from tests) must not execute a command.
|
|
527
617
|
if (import.meta.url === `file://${process.argv[1]}`) {
|