@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.
Files changed (3) hide show
  1. package/README.md +32 -6
  2. package/package.json +11 -7
  3. 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://pulsar.novacraft.africa) — bundle your React Native JavaScript, hash it exactly the way the client does, and ship it over-the-air.
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.pulsar.novacraft.africa
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.1",
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://pulsar.novacraft.africa",
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
- "react-native",
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
- "cli",
37
- "release",
38
- "hot-update"
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 [b]rowser or [t]erminal? (b/t): ')).toLowerCase();
274
- mode = answer.startsWith('t') ? 'terminal' : 'browser';
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 reach = release.clientIds?.length ? `${release.clientIds.length} device(s)` : `${release.rollout}% of devices`;
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
- console.log(` ${release.label.padEnd(5)} target ${String(release.appVersion).padEnd(10)} rollout ${String(release.rollout).padStart(3)}%${flags} ${release.description || ''}`);
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.split(',').map((id) => id.trim()).filter(Boolean);
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