pulse-updates 1.0.11 → 1.0.12
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.
- package/LICENSE +21 -0
- package/README.md +57 -8
- package/android/src/main/java/app/pulse/updates/PulseController.kt +234 -37
- package/android/src/main/java/app/pulse/updates/database/PulseDatabase.kt +14 -0
- package/android/src/main/java/app/pulse/updates/errorrecovery/PulseErrorRecovery.kt +43 -0
- package/android/src/main/java/app/pulse/updates/launcher/PulseAppLauncher.kt +11 -4
- package/ios/PulseUpdates/AppLauncher/PulseAppLauncher.swift +15 -8
- package/ios/PulseUpdates/PulseController.swift +152 -4
- package/ios/PulseUpdates/PulseTypes.swift +21 -1
- package/lib/typescript/types.d.ts +10 -0
- package/lib/typescript/types.d.ts.map +1 -1
- package/package.json +10 -8
- package/scripts/publish.mjs +176 -5
- package/src/types.ts +10 -0
package/scripts/publish.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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,41 @@ async function publish(options) {
|
|
|
712
777
|
}
|
|
713
778
|
logSuccess(`Created release: ${release.id}`);
|
|
714
779
|
|
|
780
|
+
// Crash-prediction preflight: REFUSE the publish (fail-closed) if this release references native
|
|
781
|
+
// modules that weren't in the previous good release for this runtime version — native code can't ship
|
|
782
|
+
// over the air, so it would crash the installed cohort. Override with --allow-native-change.
|
|
783
|
+
let preflight = null;
|
|
784
|
+
try {
|
|
785
|
+
const pfRes = await fetch(`${config.apiUrl}/api/releases/${release.id}/preflight`, {
|
|
786
|
+
headers: { 'X-API-Key': config.apiKey },
|
|
787
|
+
});
|
|
788
|
+
if (pfRes.ok) preflight = await pfRes.json();
|
|
789
|
+
} catch {
|
|
790
|
+
log('⚠ Preflight check could not run (server unreachable); continuing.');
|
|
791
|
+
}
|
|
792
|
+
if (preflight) {
|
|
793
|
+
for (const w of preflight.warnings || []) log(`⚠ ${w}`);
|
|
794
|
+
// Only NEW NATIVE modules are risky for OTA (native code can't ship over the air); JS changes are
|
|
795
|
+
// always fine. FAIL-CLOSED by default: a new native module referenced by this JS bundle that isn't
|
|
796
|
+
// in the previous good build for this runtime-version would CRASH the whole installed cohort on load,
|
|
797
|
+
// so we refuse the publish. Override only with --allow-native-change (you certify the native module
|
|
798
|
+
// is already in the installed binary). `--no-native-check` skips the gate entirely.
|
|
799
|
+
if (!preflight.ok && !options['no-native-check']) {
|
|
800
|
+
const allow = options['allow-native-change'] || options.force;
|
|
801
|
+
if (!allow) {
|
|
802
|
+
throw new Error(
|
|
803
|
+
`Refusing to publish: this release references native module(s) NOT in the previous good build ` +
|
|
804
|
+
`for runtime-version ${config.runtimeVersion}/${config.platform}. Native code cannot ship over ` +
|
|
805
|
+
`the air — this OTA would crash the installed cohort on load. Ship a NEW native binary (and bump ` +
|
|
806
|
+
`the runtime-version) first. If you are certain the native module is already in the installed ` +
|
|
807
|
+
`binary, re-run with --allow-native-change.`
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
logWarning('--allow-native-change: publishing despite detected native-module changes. If those ' +
|
|
811
|
+
'native modules are NOT already in the installed binary, this OTA will crash the cohort.');
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
715
815
|
// Step 5: Check which assets already exist and upload missing ones
|
|
716
816
|
logStep('5/6', 'Checking and uploading assets...');
|
|
717
817
|
const checkResponse = await fetch(`${config.apiUrl}/api/releases/${release.id}/assets/check`, {
|
|
@@ -827,6 +927,51 @@ async function publish(options) {
|
|
|
827
927
|
}
|
|
828
928
|
}
|
|
829
929
|
|
|
930
|
+
/**
|
|
931
|
+
* Register the native capability set for a runtime version (run at APP BUILD time, against the
|
|
932
|
+
* embedded JS bundle the app ships with). Powers authoritative crash-prediction in preflight.
|
|
933
|
+
*/
|
|
934
|
+
async function registerCapabilities(options) {
|
|
935
|
+
const config = loadConfig(options);
|
|
936
|
+
const bundlePath = options['bundle'] || options['embedded-bundle'];
|
|
937
|
+
if (!bundlePath) {
|
|
938
|
+
throw new Error('Provide --bundle <path to the embedded JS bundle> (the bundle built into the app binary).');
|
|
939
|
+
}
|
|
940
|
+
const nativeModules = extractNativeModules(bundlePath);
|
|
941
|
+
log(`Registering ${nativeModules.length} native module(s) for ${config.runtimeVersion}/${config.platform}`);
|
|
942
|
+
const res = await fetch(`${config.apiUrl}/api/capabilities`, {
|
|
943
|
+
method: 'POST',
|
|
944
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': config.apiKey },
|
|
945
|
+
body: JSON.stringify({
|
|
946
|
+
runtimeVersion: config.runtimeVersion,
|
|
947
|
+
platform: config.platform,
|
|
948
|
+
nativeModules,
|
|
949
|
+
}),
|
|
950
|
+
});
|
|
951
|
+
if (!res.ok) {
|
|
952
|
+
throw new Error(`Failed to register capabilities: ${res.status} ${await res.text()}`);
|
|
953
|
+
}
|
|
954
|
+
logSuccess(`Capabilities registered (${nativeModules.length} native modules)`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Print a fresh signing keypair, with copy-paste config for the server and the app.
|
|
959
|
+
*/
|
|
960
|
+
function keygen(options) {
|
|
961
|
+
const { keyId, privateKeyBase64, publicKeyBase64 } = generateSigningKeyPair(options['key-id']);
|
|
962
|
+
console.log(`
|
|
963
|
+
${colors.bright}Pulse signing keypair generated${colors.reset}
|
|
964
|
+
|
|
965
|
+
${colors.cyan}Server${colors.reset} (keep PRIVATE — set in the server secrets):
|
|
966
|
+
Pulse:SigningKeyId = ${keyId}
|
|
967
|
+
Pulse:SigningKey = ${privateKeyBase64}
|
|
968
|
+
|
|
969
|
+
${colors.cyan}App${colors.reset} (PUBLIC — set in Info.plist / AndroidManifest):
|
|
970
|
+
PulseUpdatesSigningKeyId = ${keyId}
|
|
971
|
+
PulseUpdatesSigningPublicKey = ${publicKeyBase64}
|
|
972
|
+
`);
|
|
973
|
+
}
|
|
974
|
+
|
|
830
975
|
/**
|
|
831
976
|
* Show help
|
|
832
977
|
*/
|
|
@@ -835,10 +980,12 @@ function showHelp() {
|
|
|
835
980
|
${colors.bright}Pulse Updates CLI v${VERSION}${colors.reset}
|
|
836
981
|
|
|
837
982
|
${colors.cyan}Usage:${colors.reset}
|
|
838
|
-
pulse-updates
|
|
983
|
+
pulse-updates <command> [options]
|
|
839
984
|
|
|
840
985
|
${colors.cyan}Commands:${colors.reset}
|
|
841
|
-
publish
|
|
986
|
+
publish Create bundle and publish update to server
|
|
987
|
+
keygen Generate an Ed25519 signing keypair (server + app config)
|
|
988
|
+
register-capabilities Record the embedded bundle's native capability set (crash-prediction)
|
|
842
989
|
|
|
843
990
|
${colors.cyan}Options:${colors.reset}
|
|
844
991
|
--api-url <url> API server URL (auto-detected from native config)
|
|
@@ -849,8 +996,15 @@ ${colors.cyan}Options:${colors.reset}
|
|
|
849
996
|
--bundle-dir <dir> Output directory (default: ./dist)
|
|
850
997
|
--entry-file <file> Entry file (default: index.ts)
|
|
851
998
|
--skip-bundle Skip bundle creation (use existing)
|
|
999
|
+
--dry-run Build + validate locally without creating a release on the server
|
|
1000
|
+
--allow-native-change Publish even though new native modules were detected (you certify they're
|
|
1001
|
+
already in the installed binary). Default is FAIL-CLOSED: a new native module
|
|
1002
|
+
not in the shipped build is refused (it would crash the cohort over the air).
|
|
1003
|
+
--no-native-check Skip the native-mismatch gate entirely (not recommended)
|
|
1004
|
+
--strict (deprecated — blocking on native changes is now the default)
|
|
852
1005
|
--build <number> Build number (shown in version, e.g., 1.0.0.42)
|
|
853
1006
|
--message <msg> Release message/notes
|
|
1007
|
+
--key-id <id> Key id for keygen (default: auto-generated)
|
|
854
1008
|
|
|
855
1009
|
${colors.cyan}Configuration:${colors.reset}
|
|
856
1010
|
Options can be set via (in priority order):
|
|
@@ -888,6 +1042,12 @@ async function main() {
|
|
|
888
1042
|
case 'publish':
|
|
889
1043
|
await publish(options);
|
|
890
1044
|
break;
|
|
1045
|
+
case 'register-capabilities':
|
|
1046
|
+
await registerCapabilities(options);
|
|
1047
|
+
break;
|
|
1048
|
+
case 'keygen':
|
|
1049
|
+
keygen(options);
|
|
1050
|
+
break;
|
|
891
1051
|
case 'help':
|
|
892
1052
|
case '--help':
|
|
893
1053
|
case '-h':
|
|
@@ -913,4 +1073,15 @@ async function main() {
|
|
|
913
1073
|
}
|
|
914
1074
|
}
|
|
915
1075
|
|
|
916
|
-
|
|
1076
|
+
// Only run the CLI when executed directly (not when imported by tests). realpath resolves the
|
|
1077
|
+
// bin symlink so `pulse-updates` still runs main.
|
|
1078
|
+
const isMain = (() => {
|
|
1079
|
+
try {
|
|
1080
|
+
return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
1081
|
+
} catch {
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
})();
|
|
1085
|
+
if (isMain) {
|
|
1086
|
+
main();
|
|
1087
|
+
}
|
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 {
|