@novacraft-engineering/pulsar-cli 1.2.2 → 1.2.4

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 +3 -3
  2. package/pulsar.js +34 -7
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/pulsar-cli",
3
- "version": "1.2.2",
4
- "description": "Pulsar OTA release CLI \u2014 bundle, hash, and ship React Native updates over-the-air.",
3
+ "version": "1.2.4",
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"
@@ -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"
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,10 +6,14 @@ 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
12
- // CodePushPublicKey (Info.plist). Written to `.codepushrelease` inside the bundle.
13
+ // CodePushPublicKey (Info.plist). Both native SDKs read it from <update>/CodePush/.codepushrelease
14
+ // and nowhere else; a signature anywhere else counts as no signature.
15
+ const SIGNATURE_PATH = 'CodePush/.codepushrelease';
16
+
13
17
  function signCodePushJWT(privateKeyPem, contentHash) {
14
18
  const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
15
19
  const signingInput = `${encode({alg: 'RS256', typ: 'JWT'})}.${encode({contentHash})}`;
@@ -246,8 +250,11 @@ async function buildDiffPackage(baseZip, newDir) {
246
250
  fs.writeFileSync(path.join(diffDir, 'hotcodepush.json'), JSON.stringify({deletedFiles}));
247
251
  // The signature isn't part of the content hash, so it's excluded above — but the patched bundle
248
252
  // 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'));
253
+ const signature = path.join(newDir, SIGNATURE_PATH);
254
+ if (fs.existsSync(signature)) {
255
+ fs.mkdirSync(path.dirname(path.join(diffDir, SIGNATURE_PATH)), {recursive: true});
256
+ fs.copyFileSync(signature, path.join(diffDir, SIGNATURE_PATH));
257
+ }
251
258
  return diffDir;
252
259
  }
253
260
 
@@ -417,9 +424,20 @@ function buildOnce(args) {
417
424
  const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
418
425
  bundle(platform, contentsDir);
419
426
  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.
420
430
  const privateKeyPath = getFlag(args, 'private-key');
421
- if (privateKeyPath) {
422
- fs.writeFileSync(path.join(contentsDir, '.codepushrelease'), signCodePushJWT(fs.readFileSync(privateKeyPath, 'utf8'), packageHash));
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);
423
441
  console.log('• signed release — devices with CodePushPublicKey set will verify it');
424
442
  }
425
443
  const zip = fs.readFileSync(zipContents(contentsDir));
@@ -594,10 +612,19 @@ async function cmdKeys(args) {
594
612
  console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
595
613
  }
596
614
 
597
- export {saveConfig, browserLogin};
615
+ export {saveConfig, browserLogin, signCodePushJWT, SIGNATURE_PATH};
598
616
 
599
617
  // 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]}`) {
618
+ // Compare real paths: a global install runs through a bin symlink, so argv[1] is not this file's path.
619
+ function invokedAsCli() {
620
+ try {
621
+ return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
622
+ } catch {
623
+ return false;
624
+ }
625
+ }
626
+
627
+ if (invokedAsCli()) {
601
628
  const [command, ...rest] = process.argv.slice(2);
602
629
  const commands = {
603
630
  login: cmdLogin,