@novacraft-engineering/pulsar-cli 1.2.4 → 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 +1 -1
  2. package/pulsar.js +43 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/pulsar-cli",
3
- "version": "1.2.4",
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": {
package/pulsar.js CHANGED
@@ -350,9 +350,35 @@ async function cmdApps() {
350
350
  }
351
351
  }
352
352
 
353
- // Push an already-built (and optionally signed) bundle to one app's deployment, then generate and
354
- // upload a differential patch against whatever that deployment was serving.
355
- 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));
356
382
  let prevLatest = null;
357
383
  if (!args.includes('--no-diff')) {
358
384
  try {
@@ -393,6 +419,9 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
393
419
  ].filter(Boolean);
394
420
  const reach = scopes.length ? scopes.join(' + ') : `${release.rollout}% of devices`;
395
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
+ }
396
425
  if (release.startsAt) console.log(` scheduled — goes live ${new Date(release.startsAt).toLocaleString()}`);
397
426
  if (release.rolloutRamp) console.log(` gradual rollout — ${release.rollout}% to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h`);
398
427
 
@@ -418,31 +447,18 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
418
447
  return release;
419
448
  }
420
449
 
421
- // 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.
422
452
  function buildOnce(args) {
423
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();
424
457
  const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
425
458
  bundle(platform, contentsDir);
426
459
  const packageHash = computePackageHash(contentsDir);
427
- // CI secrets arrive as environment variables holding the key itself, not as files, so the key can
428
- // come from PULSAR_SIGNING_KEY as well as --private-key <file>. An unset or empty secret means
429
- // "don't sign", exactly as before.
430
- const privateKeyPath = getFlag(args, 'private-key');
431
- const signingKey = privateKeyPath ? fs.readFileSync(privateKeyPath, 'utf8') : (process.env.PULSAR_SIGNING_KEY || '').trim();
432
- if (signingKey) {
433
- let signature;
434
- try {
435
- signature = signCodePushJWT(signingKey, packageHash);
436
- } catch (error) {
437
- die(`could not sign with the provided key (${error.message}) — nothing was uploaded`);
438
- }
439
- fs.mkdirSync(path.join(contentsDir, 'CodePush'), {recursive: true});
440
- fs.writeFileSync(path.join(contentsDir, SIGNATURE_PATH), signature);
441
- console.log('• signed release — devices with CodePushPublicKey set will verify it');
442
- }
443
- const zip = fs.readFileSync(zipContents(contentsDir));
444
- console.log(`• hash ${packageHash.slice(0, 12)}… size ${(zip.length / 1e6).toFixed(2)} MB`);
445
- return {contentsDir, packageHash, zip};
460
+ console.log(`• hash ${packageHash.slice(0, 12)}…`);
461
+ return {contentsDir, packageHash, localKey};
446
462
  }
447
463
 
448
464
  async function cmdRelease(args) {
@@ -453,8 +469,8 @@ async function cmdRelease(args) {
453
469
  if (app.os !== getFlag(args, 'platform', 'ios')) {
454
470
  console.log(`• note: resolved app "${app.name}" is ${app.os}; bundling ${getFlag(args, 'platform', 'ios')}`);
455
471
  }
456
- const {contentsDir, packageHash, zip} = buildOnce(args);
457
- await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
472
+ const {contentsDir, packageHash, localKey} = buildOnce(args);
473
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey);
458
474
  }
459
475
 
460
476
  // Release the same built bundle to several apps at once (shared-codebase / white-label fleets).
@@ -479,11 +495,11 @@ async function cmdReleaseTrain(args) {
479
495
  apps = apps.filter((app) => app.os === platform);
480
496
  if (!apps.length) die(`No ${platform} apps matched.`);
481
497
 
482
- const {contentsDir, packageHash, zip} = buildOnce(args);
498
+ const {contentsDir, packageHash, localKey} = buildOnce(args);
483
499
  console.log(`• release train → ${apps.length} app(s): ${apps.map((app) => app.name).join(', ')}`);
484
500
  for (const app of apps) {
485
501
  try {
486
- await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
502
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey);
487
503
  } catch (err) {
488
504
  console.log(`✗ ${app.name}: ${err.message}`);
489
505
  }