@novacraft-engineering/pulsar-cli 1.2.0 → 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 +13 -9
  3. package/pulsar.js +126 -38
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.0",
4
- "description": "Pulsar OTA release CLI — bundle, hash, and ship React Native updates over-the-air.",
3
+ "version": "1.2.2",
4
+ "description": "Pulsar OTA release CLI \u2014 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"
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
@@ -23,7 +23,7 @@ function cmdKeygen() {
23
23
  publicKeyEncoding: {type: 'spki', format: 'pem'},
24
24
  privateKeyEncoding: {type: 'pkcs8', format: 'pem'},
25
25
  });
26
- fs.writeFileSync('pulsar-private.pem', privateKey);
26
+ fs.writeFileSync('pulsar-private.pem', privateKey, {mode: 0o600});
27
27
  fs.writeFileSync('pulsar-public.pem', publicKey);
28
28
  console.log('✓ wrote pulsar-private.pem (keep secret, use with --private-key) and pulsar-public.pem');
29
29
  console.log(' Put the public key in Info.plist <key>CodePushPublicKey</key> to require signed updates.');
@@ -41,8 +41,11 @@ function loadConfig() {
41
41
  }
42
42
 
43
43
  function saveConfig(config) {
44
- fs.mkdirSync(CONFIG_DIR, {recursive: true});
45
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
44
+ // The config holds a bearer token — keep the dir and file owner-only so other local users on a
45
+ // shared host can't read the session.
46
+ fs.mkdirSync(CONFIG_DIR, {recursive: true, mode: 0o700});
47
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {mode: 0o600});
48
+ try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* best-effort on pre-existing files */ }
46
49
  }
47
50
 
48
51
  async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous, onUploadProgress} = {}) {
@@ -153,19 +156,26 @@ function dashboardUrl(apiUrl) {
153
156
  // Browser login: spin up a throwaway localhost server, open the dashboard's /cli-login page which
154
157
  // (once you're signed in) redirects back here with a token. No password ever touches the terminal.
155
158
  function browserLogin(apiUrl) {
159
+ // A random nonce ties the callback to THIS login: the dashboard echoes it back and we reject any
160
+ // callback whose state doesn't match, so a stray page spraying the loopback port range can't inject
161
+ // its own token into our config (login fixation).
162
+ const state = crypto.randomUUID();
156
163
  return new Promise((resolve, reject) => {
157
164
  const server = http.createServer((req, res) => {
158
165
  const requestUrl = new URL(req.url, 'http://127.0.0.1');
166
+ if (requestUrl.pathname !== '/callback') { res.writeHead(204); res.end(); return; }
159
167
  const token = requestUrl.searchParams.get('token');
160
- res.writeHead(token ? 200 : 400, {'content-type': 'text/html'});
161
- res.end(`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;background:#080a0f;color:#eef2f7;display:grid;place-items:center;height:100vh;margin:0"><div style="text-align:center"><h2 style="font-weight:600">${token ? 'Pulsar CLI authorized ✓' : 'No token received'}</h2><p style="color:#8f98a6">You can close this tab and return to your terminal.</p></div></body>`);
168
+ const ok = token && requestUrl.searchParams.get('state') === state;
169
+ res.writeHead(ok ? 200 : 400, {'content-type': 'text/html'});
170
+ res.end(`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;background:#080a0f;color:#eef2f7;display:grid;place-items:center;height:100vh;margin:0"><div style="text-align:center"><h2 style="font-weight:600">${ok ? 'Pulsar CLI authorized ✓' : 'Authorization failed'}</h2><p style="color:#8f98a6">You can close this tab and return to your terminal.</p></div></body>`);
171
+ if (!ok) return;
162
172
  setTimeout(() => { try { server.close(); } catch {} }, 100);
163
- if (token) resolve(token); else reject(new Error('No token received from the browser'));
173
+ resolve(token);
164
174
  });
165
175
  server.on('error', reject);
166
176
  server.listen(0, '127.0.0.1', () => {
167
177
  const {port} = server.address();
168
- const authUrl = `${dashboardUrl(apiUrl)}/cli-login?port=${port}`;
178
+ const authUrl = `${dashboardUrl(apiUrl)}/cli-login?port=${port}&state=${state}`;
169
179
  console.log(`\n Authorize the CLI in your browser:\n ${authUrl}\n`);
170
180
  openBrowser(authUrl);
171
181
  console.log(' Waiting for authorization…');
@@ -247,6 +257,35 @@ async function fetchBlob(apiUrl, appId, fileName) {
247
257
  return Buffer.from(await response.arrayBuffer());
248
258
  }
249
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
+
250
289
  function getFlag(args, name, fallback) {
251
290
  const index = args.indexOf(`--${name}`);
252
291
  return index >= 0 ? args[index + 1] : fallback;
@@ -258,17 +297,31 @@ async function cmdLogin(args) {
258
297
 
259
298
  let mode;
260
299
  if (args.includes('--browser')) mode = 'browser';
300
+ else if (args.includes('--api-key') || args.includes('--apikey')) mode = 'apikey';
261
301
  else if (args.includes('--terminal')) mode = 'terminal';
262
302
  else {
263
- const answer = (await prompt('Sign in with [b]rowser or [t]erminal? (b/t): ')).toLowerCase();
264
- 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;
265
318
  }
266
319
 
267
320
  if (mode === 'browser') {
268
321
  const token = await browserLogin(apiUrl);
269
322
  saveConfig({apiUrl, token});
270
323
  const me = await api('/auth/me').catch(() => null);
271
- console.log(`✓ logged in${me?.email ? ` as ${me.email}` : ''} (browser)`);
324
+ console.log(`✓ logged in${me?.user?.email ? ` as ${me.user.email}` : ''} (browser)`);
272
325
  return;
273
326
  }
274
327
 
@@ -301,6 +354,8 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
301
354
  } catch {}
302
355
  }
303
356
 
357
+ const startsAt = parseSchedule(getFlag(args, 'schedule'));
358
+ const ramp = parseRamp(getFlag(args, 'ramp'));
304
359
  const release = await api(`/apps/${app.id}/deployments/${deployment}/release`, {
305
360
  method: 'POST',
306
361
  raw: true,
@@ -314,11 +369,25 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
314
369
  'x-disabled': String(args.includes('--disabled')),
315
370
  'x-rollout': String(getFlag(args, 'rollout', '100')),
316
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', ''), ',')),
317
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)} : {}),
318
379
  },
319
380
  });
320
- 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`;
321
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`);
322
391
 
323
392
  if (prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
324
393
  try {
@@ -360,7 +429,7 @@ function buildOnce(args) {
360
429
 
361
430
  async function cmdRelease(args) {
362
431
  const [nameOrId, deployment = 'Staging'] = args;
363
- 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 "..."]');
364
433
  if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
365
434
  const app = await resolveApp(nameOrId, getFlag(args, 'platform', 'ios'));
366
435
  if (app.os !== getFlag(args, 'platform', 'ios')) {
@@ -444,13 +513,17 @@ async function cmdHistory(args) {
444
513
  if (!releases.length) return console.log('(no releases)');
445
514
  for (const release of [...releases].reverse()) {
446
515
  const flags = `${release.isMandatory ? ' [mandatory]' : ''}${release.isDisabled ? ' [disabled]' : ''}`;
447
- 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 || ''}`);
448
521
  }
449
522
  }
450
523
 
451
524
  async function cmdPatch(args) {
452
525
  const [nameOrId, deployment, label] = args;
453
- 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]');
454
527
  const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
455
528
  const body = {};
456
529
  const rollout = getFlag(args, 'rollout');
@@ -459,9 +532,19 @@ async function cmdPatch(args) {
459
532
  if (args.includes('--disable')) body.isDisabled = true;
460
533
  if (args.includes('--enable')) body.isDisabled = false;
461
534
  const devices = getFlag(args, 'devices');
462
- 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);
463
546
  const release = await api(`/apps/${app.id}/deployments/${deployment}/releases/${label}`, {method: 'PATCH', body});
464
- 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' : ''}`);
465
548
  }
466
549
 
467
550
  // Deployment keys for a whole React Native project (its -Android and -iOS apps), shaped as the
@@ -511,28 +594,33 @@ async function cmdKeys(args) {
511
594
  console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
512
595
  }
513
596
 
514
- const [command, ...rest] = process.argv.slice(2);
515
- const commands = {
516
- login: cmdLogin,
517
- apps: cmdApps,
518
- release: cmdRelease,
519
- 'release-train': cmdReleaseTrain,
520
- promote: cmdPromote,
521
- rollback: cmdRollback,
522
- deployment: cmdDeployment,
523
- history: cmdHistory,
524
- patch: cmdPatch,
525
- keygen: cmdKeygen,
526
- keys: cmdKeys,
527
- };
597
+ export {saveConfig, browserLogin};
598
+
599
+ // Only dispatch when run as the CLI — importing the module (e.g. from tests) must not execute a command.
600
+ if (import.meta.url === `file://${process.argv[1]}`) {
601
+ const [command, ...rest] = process.argv.slice(2);
602
+ const commands = {
603
+ login: cmdLogin,
604
+ apps: cmdApps,
605
+ release: cmdRelease,
606
+ 'release-train': cmdReleaseTrain,
607
+ promote: cmdPromote,
608
+ rollback: cmdRollback,
609
+ deployment: cmdDeployment,
610
+ history: cmdHistory,
611
+ patch: cmdPatch,
612
+ keygen: cmdKeygen,
613
+ keys: cmdKeys,
614
+ };
528
615
 
529
- const ANONYMOUS_COMMANDS = new Set(['login', 'keygen']);
616
+ const ANONYMOUS_COMMANDS = new Set(['login', 'keygen']);
530
617
 
531
- if (commands[command] && !ANONYMOUS_COMMANDS.has(command) && !loadConfig().token) {
532
- die('Not authenticated. Run: pulsar login <apiUrl>');
533
- }
618
+ if (commands[command] && !ANONYMOUS_COMMANDS.has(command) && !loadConfig().token) {
619
+ die('Not authenticated. Run: pulsar login <apiUrl>');
620
+ }
534
621
 
535
- (commands[command] || (() => {
536
- console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen>');
537
- process.exit(command ? 1 : 0);
538
- }))(rest);
622
+ (commands[command] || (() => {
623
+ console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen>');
624
+ process.exit(command ? 1 : 0);
625
+ }))(rest);
626
+ }