@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.
Files changed (3) hide show
  1. package/README.md +32 -6
  2. package/package.json +13 -9
  3. 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://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,7 +1,7 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/pulsar-cli",
3
- "version": "1.2.1",
4
- "description": "Pulsar OTA release CLI \u2014 bundle, hash, and ship React Native updates over-the-air.",
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://pulsar.novacraft.africa",
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
- "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
@@ -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). Written to `.codepushrelease` inside the bundle.
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, '.codepushrelease');
250
- if (fs.existsSync(signature)) fs.copyFileSync(signature, path.join(diffDir, '.codepushrelease'));
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 [b]rowser or [t]erminal? (b/t): ')).toLowerCase();
274
- mode = answer.startsWith('t') ? 'terminal' : 'browser';
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 reach = release.clientIds?.length ? `${release.clientIds.length} device(s)` : `${release.rollout}% of devices`;
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
- if (privateKeyPath) {
363
- fs.writeFileSync(path.join(contentsDir, '.codepushrelease'), signCodePushJWT(fs.readFileSync(privateKeyPath, 'utf8'), packageHash));
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
- console.log(` ${release.label.padEnd(5)} target ${String(release.appVersion).padEnd(10)} rollout ${String(release.rollout).padStart(3)}%${flags} ${release.description || ''}`);
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.split(',').map((id) => id.trim()).filter(Boolean);
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]}`) {