@novacraft-engineering/pulsar-cli 1.2.3 → 1.3.0

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 (2) hide show
  1. package/package.json +2 -2
  2. package/pulsar.js +54 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/pulsar-cli",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "Pulsar OTA release CLI — bundle, hash, and ship React Native updates over-the-air.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "node": ">=18"
27
27
  },
28
28
  "scripts": {
29
- "test": "node test/hash.test.js && node test/security.test.js && node test/signing.test.js"
29
+ "test": "node test/hash.test.js && node test/security.test.js && node test/signing.test.js && node test/entry.test.js"
30
30
  },
31
31
  "keywords": [
32
32
  "cli",
package/pulsar.js CHANGED
@@ -6,6 +6,7 @@ import readline from 'node:readline';
6
6
  import http from 'node:http';
7
7
  import {execFileSync} from 'node:child_process';
8
8
  import crypto from 'node:crypto';
9
+ import {fileURLToPath} from 'node:url';
9
10
  import {computePackageHash, fileHashes} from './hash.js';
10
11
 
11
12
  // Code signing: sign the package hash into an RS256 JWT the client verifies against its
@@ -349,9 +350,35 @@ async function cmdApps() {
349
350
  }
350
351
  }
351
352
 
352
- // Push an already-built (and optionally signed) bundle to one app's deployment, then generate and
353
- // upload a differential patch against whatever that deployment was serving.
354
- async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip) {
353
+ // Sign for this app — with a local key when one was given, otherwise by asking Pulsar, which signs for
354
+ // anyone allowed to publish here. Each app can hold a different key, so a release train re-signs.
355
+ async function signForApp(app, deployment, contentsDir, packageHash, localKey) {
356
+ const signaturePath = path.join(contentsDir, SIGNATURE_PATH);
357
+ fs.rmSync(signaturePath, {force: true});
358
+ let signature = null;
359
+ if (localKey) {
360
+ try {
361
+ signature = signCodePushJWT(localKey, packageHash);
362
+ } catch (error) {
363
+ die(`could not sign with the provided key (${error.message}) — nothing was uploaded`);
364
+ }
365
+ } else {
366
+ ({signature} = await api(`/apps/${app.id}/deployments/${deployment}/sign`, {method: 'POST', body: {packageHash}}));
367
+ }
368
+ if (!signature) {
369
+ console.log(`• ${app.name} has no signing key — uploading unsigned`);
370
+ return;
371
+ }
372
+ fs.mkdirSync(path.dirname(signaturePath), {recursive: true});
373
+ fs.writeFileSync(signaturePath, signature);
374
+ console.log(`• signed${localKey ? ' with your local key' : ' by Pulsar'}`);
375
+ }
376
+
377
+ // Sign and push an already-built bundle to one app's deployment, then generate and upload a
378
+ // differential patch against whatever that deployment was serving.
379
+ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey) {
380
+ await signForApp(app, deployment, contentsDir, packageHash, localKey);
381
+ const zip = fs.readFileSync(zipContents(contentsDir));
355
382
  let prevLatest = null;
356
383
  if (!args.includes('--no-diff')) {
357
384
  try {
@@ -392,6 +419,9 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
392
419
  ].filter(Boolean);
393
420
  const reach = scopes.length ? scopes.join(' + ') : `${release.rollout}% of devices`;
394
421
  console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}`);
422
+ if (release.signed === false) {
423
+ console.log('⚠ UNSIGNED — this build is blocked and will not reach devices. An org owner can enable it from the dashboard.');
424
+ }
395
425
  if (release.startsAt) console.log(` scheduled — goes live ${new Date(release.startsAt).toLocaleString()}`);
396
426
  if (release.rolloutRamp) console.log(` gradual rollout — ${release.rollout}% to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h`);
397
427
 
@@ -417,31 +447,18 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
417
447
  return release;
418
448
  }
419
449
 
420
- // Build the JS bundle once, sign it if requested, and return the reusable artifacts.
450
+ // Build the JS bundle once and return what every target app's release is made from. A local key
451
+ // (--private-key <file>, or PULSAR_SIGNING_KEY holding the key itself) overrides Pulsar's signing.
421
452
  function buildOnce(args) {
422
453
  const platform = getFlag(args, 'platform', 'ios');
454
+ const privateKeyPath = getFlag(args, 'private-key');
455
+ if (privateKeyPath && !fs.existsSync(privateKeyPath)) die(`no signing key at ${privateKeyPath} — drop --private-key to have Pulsar sign`);
456
+ const localKey = privateKeyPath ? fs.readFileSync(privateKeyPath, 'utf8') : (process.env.PULSAR_SIGNING_KEY || '').trim();
423
457
  const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
424
458
  bundle(platform, contentsDir);
425
459
  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.
429
- const privateKeyPath = getFlag(args, 'private-key');
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);
440
- console.log('• signed release — devices with CodePushPublicKey set will verify it');
441
- }
442
- const zip = fs.readFileSync(zipContents(contentsDir));
443
- console.log(`• hash ${packageHash.slice(0, 12)}… size ${(zip.length / 1e6).toFixed(2)} MB`);
444
- return {contentsDir, packageHash, zip};
460
+ console.log(`• hash ${packageHash.slice(0, 12)}…`);
461
+ return {contentsDir, packageHash, localKey};
445
462
  }
446
463
 
447
464
  async function cmdRelease(args) {
@@ -452,8 +469,8 @@ async function cmdRelease(args) {
452
469
  if (app.os !== getFlag(args, 'platform', 'ios')) {
453
470
  console.log(`• note: resolved app "${app.name}" is ${app.os}; bundling ${getFlag(args, 'platform', 'ios')}`);
454
471
  }
455
- const {contentsDir, packageHash, zip} = buildOnce(args);
456
- await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
472
+ const {contentsDir, packageHash, localKey} = buildOnce(args);
473
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey);
457
474
  }
458
475
 
459
476
  // Release the same built bundle to several apps at once (shared-codebase / white-label fleets).
@@ -478,11 +495,11 @@ async function cmdReleaseTrain(args) {
478
495
  apps = apps.filter((app) => app.os === platform);
479
496
  if (!apps.length) die(`No ${platform} apps matched.`);
480
497
 
481
- const {contentsDir, packageHash, zip} = buildOnce(args);
498
+ const {contentsDir, packageHash, localKey} = buildOnce(args);
482
499
  console.log(`• release train → ${apps.length} app(s): ${apps.map((app) => app.name).join(', ')}`);
483
500
  for (const app of apps) {
484
501
  try {
485
- await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
502
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey);
486
503
  } catch (err) {
487
504
  console.log(`✗ ${app.name}: ${err.message}`);
488
505
  }
@@ -614,7 +631,16 @@ async function cmdKeys(args) {
614
631
  export {saveConfig, browserLogin, signCodePushJWT, SIGNATURE_PATH};
615
632
 
616
633
  // Only dispatch when run as the CLI — importing the module (e.g. from tests) must not execute a command.
617
- if (import.meta.url === `file://${process.argv[1]}`) {
634
+ // Compare real paths: a global install runs through a bin symlink, so argv[1] is not this file's path.
635
+ function invokedAsCli() {
636
+ try {
637
+ return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
638
+ } catch {
639
+ return false;
640
+ }
641
+ }
642
+
643
+ if (invokedAsCli()) {
618
644
  const [command, ...rest] = process.argv.slice(2);
619
645
  const commands = {
620
646
  login: cmdLogin,