pulse-updates 1.0.11 → 1.0.13

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.
@@ -7,8 +7,53 @@ import fs from 'fs';
7
7
  import path from 'path';
8
8
  import crypto from 'crypto';
9
9
  import { execSync, spawnSync } from 'child_process';
10
+ import { fileURLToPath } from 'node:url';
10
11
 
11
- const VERSION = '1.0.1';
12
+ // Read from package.json at runtime so `--version` never drifts from the published package.
13
+ const VERSION = JSON.parse(
14
+ fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')
15
+ ).version;
16
+
17
+ /**
18
+ * Extract the native modules a JS bundle references, for publish-time crash-prediction.
19
+ * Native code cannot be shipped over the air, so a bundle referencing a native module that isn't
20
+ * in the installed binary will crash on launch. Scan the (pre-Hermes) plain JS for the canonical
21
+ * native-binding call sites. Heuristic, but combined with the server's diff-vs-previous-good it
22
+ * reliably flags "you added a new native dependency in a JS-only update".
23
+ */
24
+ export function extractNativeModulesFromSource(source) {
25
+ const names = new Set();
26
+ const patterns = [
27
+ /TurboModuleRegistry\.(?:getEnforcing|get)\(\s*['"]([\w.$-]+)['"]/g,
28
+ /\brequireNativeComponent\(\s*['"]([\w.$-]+)['"]/g,
29
+ /\bcodegenNativeComponent(?:<[^>]*>)?\(\s*['"]([\w.$-]+)['"]/g,
30
+ ];
31
+ for (const re of patterns) {
32
+ let m;
33
+ while ((m = re.exec(source)) !== null) names.add(m[1]);
34
+ }
35
+ return [...names].sort();
36
+ }
37
+
38
+ export function extractNativeModules(bundlePath) {
39
+ return extractNativeModulesFromSource(fs.readFileSync(bundlePath, 'utf8'));
40
+ }
41
+
42
+ /**
43
+ * Generate an Ed25519 manifest-signing keypair. Returns the raw 32-byte seed (server private key)
44
+ * and raw 32-byte public key as base64 — exactly the formats the server (Pulse:SigningKey) and the
45
+ * app (PulseUpdatesSigningPublicKey) expect.
46
+ */
47
+ export function generateSigningKeyPair(keyId) {
48
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
49
+ const privSeed = privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(-32);
50
+ const pubRaw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32);
51
+ return {
52
+ keyId: keyId || `prod-${crypto.randomBytes(4).toString('hex')}`,
53
+ privateKeyBase64: Buffer.from(privSeed).toString('base64'),
54
+ publicKeyBase64: Buffer.from(pubRaw).toString('base64'),
55
+ };
56
+ }
12
57
 
13
58
  // ANSI colors
14
59
  const colors = {
@@ -520,7 +565,7 @@ function detectRuntimeVersion(platform) {
520
565
 
521
566
  /**
522
567
  * Extract base API URL from full manifest URL
523
- * e.g., "https://pulse.lyramusic.app/pulse/manifest/lyra" -> "https://pulse.lyramusic.app"
568
+ * e.g., "https://pulse.example.com/pulse/manifest/my-app" -> "https://pulse.example.com"
524
569
  */
525
570
  function extractApiUrlFromManifestUrl(manifestUrl) {
526
571
  if (!manifestUrl) return null;
@@ -645,12 +690,19 @@ async function publish(options) {
645
690
  }
646
691
  log(`Bundle Dir: ${bundleDir}\n`);
647
692
 
693
+ let nativeModules = [];
694
+
648
695
  // Step 1: Create bundle (unless skipped)
649
696
  if (!config.skipBundle) {
650
697
  logStep('1/6', 'Creating bundle...');
651
698
  const bundlePath = createBundle(config.platform, bundleDir, config.entryFile);
652
699
  logSuccess(`Bundle created: ${bundlePath}`);
653
700
 
701
+ // Crash-prediction fingerprint: extract native module references from the plain JS bundle
702
+ // (must happen BEFORE Hermes bytecode compilation, while the bundle is still readable JS).
703
+ nativeModules = extractNativeModules(bundlePath);
704
+ log(`Native modules referenced: ${nativeModules.length}${nativeModules.length ? ` (${nativeModules.join(', ')})` : ''}`);
705
+
654
706
  // Step 2: Compile with Hermes
655
707
  logStep('2/6', 'Compiling with Hermes...');
656
708
  compileWithHermes(bundlePath, config.platform);
@@ -665,6 +717,18 @@ async function publish(options) {
665
717
  const assets = await collectAssets(bundleDir, config.platform);
666
718
  logSuccess(`Found ${assets.length} assets (1 bundle, ${assets.length - 1} assets)`);
667
719
 
720
+ // --dry-run: validate the publish locally (bundle + assets + native-module scan) without creating
721
+ // a release or touching the server. Useful in CI to catch a broken bundle before a real publish.
722
+ if (options['dry-run'] || options.dryRun) {
723
+ log(`\n${colors.bright}Dry run — nothing was published.${colors.reset}`);
724
+ log(`Would create release: runtimeVersion=${config.runtimeVersion} platform=${config.platform} channel=${config.channel}`);
725
+ log(`Assets: ${assets.length} (1 bundle + ${assets.length - 1})`);
726
+ log(`Native modules referenced: ${nativeModules.length}${nativeModules.length ? ` (${nativeModules.join(', ')})` : ''}`);
727
+ if (config.build) log(`Build: ${config.build}`);
728
+ if (config.message) log(`Message: ${config.message}`);
729
+ return;
730
+ }
731
+
668
732
  // Step 4: Create release
669
733
  logStep('4/6', 'Creating release...');
670
734
 
@@ -688,6 +752,7 @@ async function publish(options) {
688
752
  platform: config.platform,
689
753
  channel: config.channel,
690
754
  metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
755
+ nativeModules: nativeModules.length ? nativeModules : undefined,
691
756
  }),
692
757
  });
693
758
 
@@ -712,6 +777,33 @@ async function publish(options) {
712
777
  }
713
778
  logSuccess(`Created release: ${release.id}`);
714
779
 
780
+ // Crash-prediction preflight: warn (and abort unless --force) if this release references native
781
+ // modules that weren't in the previous good release for this runtime version.
782
+ let preflight = null;
783
+ try {
784
+ const pfRes = await fetch(`${config.apiUrl}/api/releases/${release.id}/preflight`, {
785
+ headers: { 'X-API-Key': config.apiKey },
786
+ });
787
+ if (pfRes.ok) preflight = await pfRes.json();
788
+ } catch {
789
+ log('⚠ Preflight check could not run (server unreachable); continuing.');
790
+ }
791
+ if (preflight) {
792
+ for (const w of preflight.warnings || []) log(`⚠ ${w}`);
793
+ // Only NEW NATIVE modules are risky for OTA (native code can't ship over the air); JS changes
794
+ // are always fine. Warn by default; only block when the publisher opts into --strict.
795
+ if (!preflight.ok) {
796
+ if (options.strict) {
797
+ throw new Error(
798
+ 'Aborting (--strict): this release references native modules new to it — they cannot ship ' +
799
+ 'over the air. Ship them in a new native binary first.'
800
+ );
801
+ }
802
+ log('⚠ Continuing: native-module changes detected (above). Only matters if those native modules ' +
803
+ 'are not already in the installed binary. Re-run with --strict to block on this.');
804
+ }
805
+ }
806
+
715
807
  // Step 5: Check which assets already exist and upload missing ones
716
808
  logStep('5/6', 'Checking and uploading assets...');
717
809
  const checkResponse = await fetch(`${config.apiUrl}/api/releases/${release.id}/assets/check`, {
@@ -827,6 +919,51 @@ async function publish(options) {
827
919
  }
828
920
  }
829
921
 
922
+ /**
923
+ * Register the native capability set for a runtime version (run at APP BUILD time, against the
924
+ * embedded JS bundle the app ships with). Powers authoritative crash-prediction in preflight.
925
+ */
926
+ async function registerCapabilities(options) {
927
+ const config = loadConfig(options);
928
+ const bundlePath = options['bundle'] || options['embedded-bundle'];
929
+ if (!bundlePath) {
930
+ throw new Error('Provide --bundle <path to the embedded JS bundle> (the bundle built into the app binary).');
931
+ }
932
+ const nativeModules = extractNativeModules(bundlePath);
933
+ log(`Registering ${nativeModules.length} native module(s) for ${config.runtimeVersion}/${config.platform}`);
934
+ const res = await fetch(`${config.apiUrl}/api/capabilities`, {
935
+ method: 'POST',
936
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': config.apiKey },
937
+ body: JSON.stringify({
938
+ runtimeVersion: config.runtimeVersion,
939
+ platform: config.platform,
940
+ nativeModules,
941
+ }),
942
+ });
943
+ if (!res.ok) {
944
+ throw new Error(`Failed to register capabilities: ${res.status} ${await res.text()}`);
945
+ }
946
+ logSuccess(`Capabilities registered (${nativeModules.length} native modules)`);
947
+ }
948
+
949
+ /**
950
+ * Print a fresh signing keypair, with copy-paste config for the server and the app.
951
+ */
952
+ function keygen(options) {
953
+ const { keyId, privateKeyBase64, publicKeyBase64 } = generateSigningKeyPair(options['key-id']);
954
+ console.log(`
955
+ ${colors.bright}Pulse signing keypair generated${colors.reset}
956
+
957
+ ${colors.cyan}Server${colors.reset} (keep PRIVATE — set in the server secrets):
958
+ Pulse:SigningKeyId = ${keyId}
959
+ Pulse:SigningKey = ${privateKeyBase64}
960
+
961
+ ${colors.cyan}App${colors.reset} (PUBLIC — set in Info.plist / AndroidManifest):
962
+ PulseUpdatesSigningKeyId = ${keyId}
963
+ PulseUpdatesSigningPublicKey = ${publicKeyBase64}
964
+ `);
965
+ }
966
+
830
967
  /**
831
968
  * Show help
832
969
  */
@@ -835,10 +972,12 @@ function showHelp() {
835
972
  ${colors.bright}Pulse Updates CLI v${VERSION}${colors.reset}
836
973
 
837
974
  ${colors.cyan}Usage:${colors.reset}
838
- pulse-updates publish [options]
975
+ pulse-updates <command> [options]
839
976
 
840
977
  ${colors.cyan}Commands:${colors.reset}
841
- publish Create bundle and publish update to server
978
+ publish Create bundle and publish update to server
979
+ keygen Generate an Ed25519 signing keypair (server + app config)
980
+ register-capabilities Record the embedded bundle's native capability set (crash-prediction)
842
981
 
843
982
  ${colors.cyan}Options:${colors.reset}
844
983
  --api-url <url> API server URL (auto-detected from native config)
@@ -849,8 +988,11 @@ ${colors.cyan}Options:${colors.reset}
849
988
  --bundle-dir <dir> Output directory (default: ./dist)
850
989
  --entry-file <file> Entry file (default: index.ts)
851
990
  --skip-bundle Skip bundle creation (use existing)
991
+ --dry-run Build + validate locally without creating a release on the server
992
+ --strict Abort publish if new native modules are detected (default: warn only)
852
993
  --build <number> Build number (shown in version, e.g., 1.0.0.42)
853
994
  --message <msg> Release message/notes
995
+ --key-id <id> Key id for keygen (default: auto-generated)
854
996
 
855
997
  ${colors.cyan}Configuration:${colors.reset}
856
998
  Options can be set via (in priority order):
@@ -888,6 +1030,12 @@ async function main() {
888
1030
  case 'publish':
889
1031
  await publish(options);
890
1032
  break;
1033
+ case 'register-capabilities':
1034
+ await registerCapabilities(options);
1035
+ break;
1036
+ case 'keygen':
1037
+ keygen(options);
1038
+ break;
891
1039
  case 'help':
892
1040
  case '--help':
893
1041
  case '-h':
@@ -913,4 +1061,15 @@ async function main() {
913
1061
  }
914
1062
  }
915
1063
 
916
- main();
1064
+ // Only run the CLI when executed directly (not when imported by tests). realpath resolves the
1065
+ // bin symlink so `pulse-updates` still runs main.
1066
+ const isMain = (() => {
1067
+ try {
1068
+ return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
1069
+ } catch {
1070
+ return false;
1071
+ }
1072
+ })();
1073
+ if (isMain) {
1074
+ main();
1075
+ }
package/src/types.ts CHANGED
@@ -55,6 +55,16 @@ export interface PulseUpdatesConfig {
55
55
  channel?: string;
56
56
  signingKeyId?: string;
57
57
  signingPublicKey?: string;
58
+ /**
59
+ * When true, manifests must carry a valid Ed25519 signature verifying against
60
+ * `signingPublicKey`; unsigned or unverifiable manifests are rejected (fail-closed).
61
+ *
62
+ * NATIVE-CONFIG ONLY: this is read from the Info.plist / AndroidManifest
63
+ * `PulseUpdatesRequireSignature` meta-data (defaults to `true` in release) and is NOT
64
+ * accepted by `configure()` — setting it here has no effect. Configure signing via the
65
+ * native config so a release can never silently flip to refuse-all from JS.
66
+ */
67
+ requireSignature?: boolean;
58
68
  }
59
69
 
60
70
  export interface PulseUpdatesState {