@novacraft-engineering/pulsar-cli 1.2.1 → 1.2.2
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 +11 -7
- package/pulsar.js +82 -9
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/pulsar-cli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Pulsar OTA release CLI \u2014 bundle, hash, and ship React Native updates over-the-air.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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": {
|
|
@@ -29,12 +29,16 @@
|
|
|
29
29
|
"test": "node test/hash.test.js && node test/security.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
|
@@ -257,6 +257,35 @@ async function fetchBlob(apiUrl, appId, fileName) {
|
|
|
257
257
|
return Buffer.from(await response.arrayBuffer());
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
const splitList = (value, separator) => String(value || '').split(separator).map((entry) => entry.trim()).filter(Boolean);
|
|
261
|
+
|
|
262
|
+
// --schedule takes either an absolute instant ("2026-09-20T09:00:00Z", "2026-09-20 09:00") or a
|
|
263
|
+
// relative offset from now ("+90m", "+6h", "+3d"). Resolved here so the API only sees ISO.
|
|
264
|
+
function parseSchedule(value) {
|
|
265
|
+
if (value === undefined) return null;
|
|
266
|
+
const raw = String(value).trim();
|
|
267
|
+
const relative = raw.match(/^\+(\d+(?:\.\d+)?)\s*(m|h|d)$/i);
|
|
268
|
+
if (relative) {
|
|
269
|
+
const unit = {m: 60000, h: 3600000, d: 86400000}[relative[2].toLowerCase()];
|
|
270
|
+
return new Date(Date.now() + Number(relative[1]) * unit).toISOString();
|
|
271
|
+
}
|
|
272
|
+
const at = Date.parse(raw.includes('T') || !raw.includes(' ') ? raw : raw.replace(' ', 'T'));
|
|
273
|
+
if (!Number.isFinite(at)) die(`Invalid --schedule "${raw}". Use an ISO instant or a relative offset like +6h.`);
|
|
274
|
+
if (at <= Date.now()) die(`--schedule "${raw}" is in the past.`);
|
|
275
|
+
return new Date(at).toISOString();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --ramp takes "<to>@<hours>" ("100@48") or just "<hours>" (ramps to 100).
|
|
279
|
+
function parseRamp(value) {
|
|
280
|
+
if (value === undefined) return null;
|
|
281
|
+
const raw = String(value).trim();
|
|
282
|
+
const match = raw.match(/^(?:(\d{1,3})@)?(\d+(?:\.\d+)?)$/);
|
|
283
|
+
if (!match) die(`Invalid --ramp "${raw}". Use <toPercent>@<hours>, e.g. 100@48.`);
|
|
284
|
+
const to = match[1] === undefined ? 100 : Number(match[1]);
|
|
285
|
+
if (to > 100) die('--ramp target cannot exceed 100.');
|
|
286
|
+
return {to, hours: Number(match[2])};
|
|
287
|
+
}
|
|
288
|
+
|
|
260
289
|
function getFlag(args, name, fallback) {
|
|
261
290
|
const index = args.indexOf(`--${name}`);
|
|
262
291
|
return index >= 0 ? args[index + 1] : fallback;
|
|
@@ -268,17 +297,31 @@ async function cmdLogin(args) {
|
|
|
268
297
|
|
|
269
298
|
let mode;
|
|
270
299
|
if (args.includes('--browser')) mode = 'browser';
|
|
300
|
+
else if (args.includes('--api-key') || args.includes('--apikey')) mode = 'apikey';
|
|
271
301
|
else if (args.includes('--terminal')) mode = 'terminal';
|
|
272
302
|
else {
|
|
273
|
-
const answer = (await prompt('Sign in with [
|
|
274
|
-
mode = answer.startsWith('
|
|
303
|
+
const answer = (await prompt('Sign in with [a]pi key or [b]rowser? (a/b): ')).toLowerCase();
|
|
304
|
+
mode = answer.startsWith('a') ? 'apikey' : 'browser';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (mode === 'apikey') {
|
|
308
|
+
const token = (await prompt('Paste your Pulsar API key: ', true)).trim();
|
|
309
|
+
if (!token) die('No API key provided.');
|
|
310
|
+
saveConfig({apiUrl, token});
|
|
311
|
+
const me = await api('/auth/me').catch(() => null);
|
|
312
|
+
if (!me?.user?.email) {
|
|
313
|
+
saveConfig({apiUrl, token: undefined});
|
|
314
|
+
die('That API key was rejected. Create one in the dashboard → Settings → API keys.');
|
|
315
|
+
}
|
|
316
|
+
console.log(`✓ logged in as ${me.user.email} (api key)`);
|
|
317
|
+
return;
|
|
275
318
|
}
|
|
276
319
|
|
|
277
320
|
if (mode === 'browser') {
|
|
278
321
|
const token = await browserLogin(apiUrl);
|
|
279
322
|
saveConfig({apiUrl, token});
|
|
280
323
|
const me = await api('/auth/me').catch(() => null);
|
|
281
|
-
console.log(`✓ logged in${me?.email ? ` as ${me.email}` : ''} (browser)`);
|
|
324
|
+
console.log(`✓ logged in${me?.user?.email ? ` as ${me.user.email}` : ''} (browser)`);
|
|
282
325
|
return;
|
|
283
326
|
}
|
|
284
327
|
|
|
@@ -311,6 +354,8 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
311
354
|
} catch {}
|
|
312
355
|
}
|
|
313
356
|
|
|
357
|
+
const startsAt = parseSchedule(getFlag(args, 'schedule'));
|
|
358
|
+
const ramp = parseRamp(getFlag(args, 'ramp'));
|
|
314
359
|
const release = await api(`/apps/${app.id}/deployments/${deployment}/release`, {
|
|
315
360
|
method: 'POST',
|
|
316
361
|
raw: true,
|
|
@@ -324,11 +369,25 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
324
369
|
'x-disabled': String(args.includes('--disabled')),
|
|
325
370
|
'x-rollout': String(getFlag(args, 'rollout', '100')),
|
|
326
371
|
'x-client-ids': getFlag(args, 'devices', ''),
|
|
372
|
+
// JSON-encoded: iOS model ids contain a comma ("iPhone14,3"), so separate models with ';'.
|
|
373
|
+
'x-target-devices': JSON.stringify(splitList(getFlag(args, 'device-models', ''), ';')),
|
|
374
|
+
'x-target-users': JSON.stringify(splitList(getFlag(args, 'users', ''), ',')),
|
|
375
|
+
'x-target-from': JSON.stringify(splitList(getFlag(args, 'from', ''), ',')),
|
|
327
376
|
'x-description': encodeURIComponent(getFlag(args, 'description', '')),
|
|
377
|
+
...(startsAt ? {'x-starts-at': startsAt} : {}),
|
|
378
|
+
...(ramp ? {'x-rollout-to': String(ramp.to), 'x-rollout-hours': String(ramp.hours)} : {}),
|
|
328
379
|
},
|
|
329
380
|
});
|
|
330
|
-
const
|
|
381
|
+
const scopes = [
|
|
382
|
+
release.clientIds?.length ? `${release.clientIds.length} device id(s)` : null,
|
|
383
|
+
release.targetDevices?.length ? `${release.targetDevices.length} device model(s)` : null,
|
|
384
|
+
release.targetUserIds?.length ? `${release.targetUserIds.length} user(s)` : null,
|
|
385
|
+
release.targetFromLabels?.length ? `devices on ${release.targetFromLabels.join('/')}` : null,
|
|
386
|
+
].filter(Boolean);
|
|
387
|
+
const reach = scopes.length ? scopes.join(' + ') : `${release.rollout}% of devices`;
|
|
331
388
|
console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}`);
|
|
389
|
+
if (release.startsAt) console.log(` scheduled — goes live ${new Date(release.startsAt).toLocaleString()}`);
|
|
390
|
+
if (release.rolloutRamp) console.log(` gradual rollout — ${release.rollout}% to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h`);
|
|
332
391
|
|
|
333
392
|
if (prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
|
|
334
393
|
try {
|
|
@@ -370,7 +429,7 @@ function buildOnce(args) {
|
|
|
370
429
|
|
|
371
430
|
async function cmdRelease(args) {
|
|
372
431
|
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 "..."]');
|
|
432
|
+
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
433
|
if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
|
|
375
434
|
const app = await resolveApp(nameOrId, getFlag(args, 'platform', 'ios'));
|
|
376
435
|
if (app.os !== getFlag(args, 'platform', 'ios')) {
|
|
@@ -454,13 +513,17 @@ async function cmdHistory(args) {
|
|
|
454
513
|
if (!releases.length) return console.log('(no releases)');
|
|
455
514
|
for (const release of [...releases].reverse()) {
|
|
456
515
|
const flags = `${release.isMandatory ? ' [mandatory]' : ''}${release.isDisabled ? ' [disabled]' : ''}`;
|
|
457
|
-
|
|
516
|
+
const rollout = release.currentRollout ?? release.rollout;
|
|
517
|
+
const state = release.state && release.state !== 'live' ? ` [${release.state}]` : '';
|
|
518
|
+
const when = release.startsAt ? ` starts ${new Date(release.startsAt).toLocaleString()}` : '';
|
|
519
|
+
const ramping = release.rolloutRamp ? ` ->${release.rolloutRamp.to}%/${release.rolloutRamp.hours}h` : '';
|
|
520
|
+
console.log(` ${release.label.padEnd(5)} target ${String(release.appVersion).padEnd(10)} rollout ${String(rollout).padStart(3)}%${ramping}${flags}${state}${when} ${release.description || ''}`);
|
|
458
521
|
}
|
|
459
522
|
}
|
|
460
523
|
|
|
461
524
|
async function cmdPatch(args) {
|
|
462
525
|
const [nameOrId, deployment, label] = args;
|
|
463
|
-
if (!label) die('Usage: pulsar patch <app> <deployment> <label> [--rollout N] [--mandatory] [--disable] [--enable] [--devices id1,id2,...]');
|
|
526
|
+
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
527
|
const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
|
|
465
528
|
const body = {};
|
|
466
529
|
const rollout = getFlag(args, 'rollout');
|
|
@@ -469,9 +532,19 @@ async function cmdPatch(args) {
|
|
|
469
532
|
if (args.includes('--disable')) body.isDisabled = true;
|
|
470
533
|
if (args.includes('--enable')) body.isDisabled = false;
|
|
471
534
|
const devices = getFlag(args, 'devices');
|
|
472
|
-
if (devices !== undefined) body.clientIds = devices
|
|
535
|
+
if (devices !== undefined) body.clientIds = splitList(devices, ',');
|
|
536
|
+
const deviceModels = getFlag(args, 'device-models');
|
|
537
|
+
if (deviceModels !== undefined) body.targetDevices = splitList(deviceModels, ';');
|
|
538
|
+
const users = getFlag(args, 'users');
|
|
539
|
+
if (users !== undefined) body.targetUserIds = splitList(users, ',');
|
|
540
|
+
const from = getFlag(args, 'from');
|
|
541
|
+
if (from !== undefined) body.targetFromLabels = from === 'none' ? [] : splitList(from, ',');
|
|
542
|
+
const schedule = getFlag(args, 'schedule');
|
|
543
|
+
if (schedule !== undefined) body.startsAt = schedule === 'none' ? null : parseSchedule(schedule);
|
|
544
|
+
const ramp = getFlag(args, 'ramp');
|
|
545
|
+
if (ramp !== undefined) body.rolloutRamp = ramp === 'none' ? null : parseRamp(ramp);
|
|
473
546
|
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' : ''}`);
|
|
547
|
+
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
548
|
}
|
|
476
549
|
|
|
477
550
|
// Deployment keys for a whole React Native project (its -Android and -iOS apps), shaped as the
|