@novacraft-engineering/pulsar-cli 1.4.0 → 1.5.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.
package/README.md CHANGED
@@ -25,7 +25,9 @@ pulsar login <apiUrl> # authenticate (API key or browser), saved
25
25
  pulsar apps # list your apps and deployment keys
26
26
 
27
27
  pulsar release <app> <deployment> --platform ios|android --target <ver> \
28
- [--rollout 25] [--mandatory] [--disabled] [--devices id1,id2,...] [--description "fix crash"]
28
+ [--rollout 25] [--mandatory] [--disabled] [--devices id1,id2,...] [--description "fix crash"] \
29
+ [--private-key key.pem] [--bundle-key <file>]
30
+ [--plain-copy-for-old-builds | --plain | --only-older-builds]
29
31
 
30
32
  pulsar promote <app> <from> <to> [--rollout 100]
31
33
  pulsar rollback <app> <deployment>
@@ -35,6 +37,11 @@ pulsar deployment add <app> <name> # create a new deployment (e.g. QA)
35
37
  pulsar history <app> <deployment> # release history
36
38
  pulsar patch <app> <deployment> <label> [--rollout N] [--mandatory] [--disable] [--enable] [--devices id1,id2,...]
37
39
  pulsar migrate --from <serverUrl> --access-key <key> # move apps from RevoPush / code-push-server (see below)
40
+
41
+ pulsar keygen # write pulsar-private.pem + pulsar-public.pem for code signing
42
+ pulsar bundle-key create --app <name> [--out <file>] # create a private-bundles key (see below)
43
+ pulsar bundle-key id [--bundle-key <file>] # print the key ids in PULSAR_BUNDLE_KEY or the file
44
+ pulsar changes --app <name> --deployment <dep> --label <vN> # unlock a private release locally, print what changed
38
45
  ```
39
46
 
40
47
  ### Targeting a release's reach
@@ -53,6 +60,74 @@ then downloads it in the background and swaps it in on next launch.
53
60
 
54
61
  Auth is read from `PULSAR_API_URL` / `PULSAR_TOKEN` env, or `~/.pulsar/config.json` (written by `pulsar login`). Nothing is committed.
55
62
 
63
+ ## Code signing
64
+
65
+ ```bash
66
+ pulsar keygen
67
+ ```
68
+
69
+ Writes `pulsar-private.pem` (mode 0600) and `pulsar-public.pem` in the current folder. Put the
70
+ public key in your app as `CodePushPublicKey` (iOS `Info.plist`, Android `strings.xml`). From then on
71
+ the device rejects any update not signed with the matching private key.
72
+
73
+ - `--private-key <file>` signs the release locally with that key.
74
+ - `PULSAR_SIGNING_KEY` does the same with the key itself in the environment, which suits CI.
75
+ - With neither, the CLI asks Pulsar to sign with the app's stored key. If Pulsar holds no key for
76
+ the app, the release goes out unsigned and the CLI says so.
77
+
78
+ ## Private bundles (encrypted releases)
79
+
80
+ When an app's Private bundles setting is Manual unlock or Auto unlock, `pulsar release` encrypts
81
+ the bundle, diff, manifest and source map before upload, so Pulsar stores and serves only ciphertext.
82
+ It is not protection against reverse engineering: the key ships inside your store build. Full guide:
83
+ [usepulsar.dev/docs/private-bundles](https://usepulsar.dev/docs/private-bundles).
84
+
85
+ ```bash
86
+ pulsar bundle-key create --app MyApp
87
+ ```
88
+
89
+ Generates a key, writes `pulsar-bundle-key-MyApp.txt` (mode 0600, never overwrites an existing
90
+ file; `--out <file>` to choose the path), registers the key id with Pulsar and prints the
91
+ `Info.plist` / `strings.xml` snippets. Back the key up: Pulsar can't recover it, and builds that
92
+ carry only a lost key can't receive OTAs until a store build ships a new one.
93
+
94
+ The key string is `kid:base64key`, comma separated for several keys. The same string goes in
95
+ `PULSAR_BUNDLE_KEY` and in `CodePushBundleKeys` (SDK 1.8 or later):
96
+
97
+ ```xml
98
+ <!-- iOS Info.plist -->
99
+ <key>CodePushBundleKeys</key><string>KID:BASE64KEY</string>
100
+
101
+ <!-- Android res/values/strings.xml -->
102
+ <string moduleConfig="true" name="CodePushBundleKeys">KID:BASE64KEY</string>
103
+ ```
104
+
105
+ Releasing a private app needs the bundle key and a local signing key:
106
+
107
+ ```bash
108
+ export PULSAR_BUNDLE_KEY="$(cat pulsar-bundle-key-MyApp.txt)"
109
+ export PULSAR_SIGNING_KEY="$(cat pulsar-private.pem)"
110
+ pulsar release MyApp Production --platform ios --target 1.4.0
111
+ ```
112
+
113
+ - `--bundle-key <file>` reads the key string from a file instead of `PULSAR_BUNDLE_KEY`.
114
+ - `--plain-copy-for-old-builds` also uploads the signed, unencrypted zip of the same release, so
115
+ builds that can't open the encrypted copy still get the fix. Pulsar can read that copy.
116
+ - `--plain` releases unencrypted on purpose while private bundles are on: every build gets it, and
117
+ Pulsar can read it.
118
+ - `--only-older-builds` releases unencrypted only to builds that can't open private releases; builds
119
+ holding the key keep their encrypted release.
120
+ - `pulsar history` tags releases `[private]`, `[private + plain copy]` and `[plain, older builds only]`.
121
+ - Each encrypted release prints its reach (devices that will get it, devices that stay on their
122
+ current bundle). A mandatory encrypted release with no reach and no plain copy is blocked.
123
+ - The CLI warns when a key registered for the app is missing from the keys you gave it, and wraps
124
+ each release for every key it is given, which is how you rotate.
125
+ - Older builds are never sent a release they can't open; they stay on their current bundle.
126
+
127
+ `pulsar changes --app MyApp --deployment Production --label v12` downloads that release's encrypted
128
+ manifests and source maps, unlocks them on your machine and prints what changed. The key never
129
+ leaves your machine.
130
+
56
131
  ## Migrating from RevoPush / code-push-server
57
132
 
58
133
  `pulsar migrate` copies apps from any server that speaks the CodePush management API
@@ -99,6 +174,8 @@ Both env vars are all a GitHub Actions / GitLab CI job needs:
99
174
  PULSAR_TOKEN: ${{ secrets.PULSAR_TOKEN }}
100
175
  ```
101
176
 
177
+ Add `PULSAR_SIGNING_KEY` to sign locally, and `PULSAR_BUNDLE_KEY` for apps with private bundles on.
178
+
102
179
  Or connect the repo in the dashboard and fire a release for any commit without leaving the browser.
103
180
 
104
181
  ## Related
package/envelope.js ADDED
@@ -0,0 +1,215 @@
1
+ const MAGIC = [0x50, 0x4c, 0x53, 0x42];
2
+ const VERSION = 1;
3
+ const PREFIX_LENGTH = 9;
4
+ const MAX_HEADER = 16384;
5
+ export const SEGMENT_SIZE = 65536;
6
+ const TAG_LENGTH = 16;
7
+ export const ALGORITHM = 'A256GCM-SEG64K';
8
+ export const KINDS = ['full', 'diff', 'manifest', 'sourcemap'];
9
+ export const SEALED_CONTENT_TYPE = 'application/vnd.pulsar.sealed';
10
+
11
+ const KID_PATTERN = /^[0-9a-f]{16}$/;
12
+ const HASH_PATTERN = /^[0-9a-f]{64}$/;
13
+ const encoder = new TextEncoder();
14
+ const decoder = new TextDecoder();
15
+ const subtle = () => globalThis.crypto.subtle;
16
+
17
+ export function toBase64(bytes) {
18
+ let binary = '';
19
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
20
+ for (let offset = 0; offset < view.length; offset += 0x8000) {
21
+ binary += String.fromCharCode(...view.subarray(offset, offset + 0x8000));
22
+ }
23
+ return btoa(binary);
24
+ }
25
+
26
+ export function fromBase64(text) {
27
+ const binary = atob(String(text));
28
+ const bytes = new Uint8Array(binary.length);
29
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
30
+ return bytes;
31
+ }
32
+
33
+ const toHex = (bytes) => [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
34
+
35
+ export async function kidOf(rawKey) {
36
+ const digest = new Uint8Array(await subtle().digest('SHA-256', rawKey));
37
+ return toHex(digest.subarray(0, 8));
38
+ }
39
+
40
+ export async function generateKeyString() {
41
+ const raw = globalThis.crypto.getRandomValues(new Uint8Array(32));
42
+ return `${await kidOf(raw)}:${toBase64(raw)}`;
43
+ }
44
+
45
+ export async function parseKeys(keyString) {
46
+ const entries = String(keyString || '')
47
+ .split(/[\s,]+/)
48
+ .map((entry) => entry.trim())
49
+ .filter(Boolean);
50
+ if (!entries.length) throw new Error('No bundle key given');
51
+ const keys = [];
52
+ for (const entry of entries) {
53
+ const [kid, encoded, extra] = entry.split(':');
54
+ if (extra !== undefined || !KID_PATTERN.test(kid || '') || !encoded) throw new Error('A bundle key must look like <kid>:<base64 key>');
55
+ let raw;
56
+ try {
57
+ raw = fromBase64(encoded);
58
+ } catch {
59
+ throw new Error(`Bundle key ${kid} is not valid base64`);
60
+ }
61
+ if (raw.length !== 32) throw new Error(`Bundle key ${kid} must be 32 bytes`);
62
+ if ((await kidOf(raw)) !== kid) throw new Error(`Bundle key ${kid} does not match its key id`);
63
+ keys.push({kid, key: raw});
64
+ }
65
+ return keys;
66
+ }
67
+
68
+ async function aesKey(material, usages) {
69
+ if (material instanceof Uint8Array || material instanceof ArrayBuffer) {
70
+ return subtle().importKey('raw', material, 'AES-GCM', false, usages);
71
+ }
72
+ return material;
73
+ }
74
+
75
+ export function isSealed(bytes) {
76
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
77
+ return view.length >= 4 && MAGIC.every((value, index) => view[index] === value);
78
+ }
79
+
80
+ function validateHeader(header) {
81
+ if (!header || typeof header !== 'object' || Array.isArray(header)) throw new Error('Envelope header is not an object');
82
+ if (header.alg !== ALGORITHM) throw new Error('Unsupported envelope algorithm');
83
+ if (!KINDS.includes(header.kind)) throw new Error('Unknown envelope kind');
84
+ if (typeof header.appId !== 'string' || !header.appId || header.appId.length > 128) throw new Error('Envelope header has no app id');
85
+ if (!HASH_PATTERN.test(header.packageHash || '')) throw new Error('Envelope header has no package hash');
86
+ if (header.kind === 'diff' && !HASH_PATTERN.test(header.baseHash || '')) throw new Error('Diff envelope has no base hash');
87
+ if (fromBase64(header.nonce || '').length !== 7) throw new Error('Envelope nonce must be 7 bytes');
88
+ if (!Array.isArray(header.keys) || !header.keys.length || header.keys.length > 16) throw new Error('Envelope must wrap 1 to 16 keys');
89
+ for (const entry of header.keys) {
90
+ if (!KID_PATTERN.test(entry?.kid || '')) throw new Error('Envelope key id is malformed');
91
+ if (fromBase64(entry.iv || '').length !== 12 || fromBase64(entry.wrapped || '').length !== 48) throw new Error('Envelope key entry is malformed');
92
+ }
93
+ }
94
+
95
+ export function parseEnvelope(bytes) {
96
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
97
+ if (!isSealed(view)) throw new Error('Not a sealed Pulsar file');
98
+ if (view.length < PREFIX_LENGTH || view[4] !== VERSION) throw new Error('Unsupported envelope version');
99
+ const headerLength = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(5);
100
+ if (headerLength < 1 || headerLength > MAX_HEADER || PREFIX_LENGTH + headerLength > view.length) throw new Error('Envelope header length is out of range');
101
+ const headerBytes = view.subarray(PREFIX_LENGTH, PREFIX_LENGTH + headerLength);
102
+ let header;
103
+ try {
104
+ header = JSON.parse(decoder.decode(headerBytes));
105
+ } catch {
106
+ throw new Error('Envelope header is not JSON');
107
+ }
108
+ validateHeader(header);
109
+ return {header, headerBytes, payloadOffset: PREFIX_LENGTH + headerLength};
110
+ }
111
+
112
+ const wrapContext = (kid, header) => encoder.encode(`pulsar-dek|v1|${kid}|${header.appId}|${header.packageHash}|${header.kind}`);
113
+
114
+ function segmentNonce(prefix, index, last) {
115
+ const nonce = new Uint8Array(12);
116
+ nonce.set(prefix, 0);
117
+ new DataView(nonce.buffer).setUint32(7, index);
118
+ nonce[11] = last ? 1 : 0;
119
+ return nonce;
120
+ }
121
+
122
+ export async function seal({plaintext, keys, appId, packageHash, kind, baseHash}, {random = (length) => globalThis.crypto.getRandomValues(new Uint8Array(length))} = {}) {
123
+ const body = plaintext instanceof Uint8Array ? plaintext : new Uint8Array(plaintext);
124
+ if (!keys?.length) throw new Error('No bundle key given');
125
+ const dataKeyRaw = random(32);
126
+ const header = {
127
+ alg: ALGORITHM,
128
+ nonce: toBase64(random(7)),
129
+ appId,
130
+ packageHash,
131
+ kind,
132
+ ...(kind === 'diff' ? {baseHash} : {}),
133
+ keys: [],
134
+ };
135
+ for (const {kid, key} of keys) {
136
+ const iv = random(12);
137
+ const wrapped = await subtle().encrypt({name: 'AES-GCM', iv, additionalData: wrapContext(kid, header)}, await aesKey(key, ['encrypt']), dataKeyRaw);
138
+ header.keys.push({kid, iv: toBase64(iv), wrapped: toBase64(new Uint8Array(wrapped))});
139
+ }
140
+ validateHeader(header);
141
+ const headerBytes = encoder.encode(JSON.stringify(header));
142
+ if (headerBytes.length > MAX_HEADER) throw new Error('Envelope header is too large');
143
+ const aad = new Uint8Array(await subtle().digest('SHA-256', headerBytes));
144
+ const dataKey = await subtle().importKey('raw', dataKeyRaw, 'AES-GCM', false, ['encrypt']);
145
+ const prefix = fromBase64(header.nonce);
146
+ const segmentCount = Math.max(1, Math.ceil(body.length / SEGMENT_SIZE));
147
+ const parts = [];
148
+ for (let index = 0; index < segmentCount; index++) {
149
+ const chunk = body.subarray(index * SEGMENT_SIZE, (index + 1) * SEGMENT_SIZE);
150
+ const iv = segmentNonce(prefix, index, index === segmentCount - 1);
151
+ parts.push(new Uint8Array(await subtle().encrypt({name: 'AES-GCM', iv, additionalData: aad}, dataKey, chunk)));
152
+ }
153
+ const total = PREFIX_LENGTH + headerBytes.length + parts.reduce((sum, part) => sum + part.length, 0);
154
+ const output = new Uint8Array(total);
155
+ output.set(MAGIC, 0);
156
+ output[4] = VERSION;
157
+ new DataView(output.buffer).setUint32(5, headerBytes.length);
158
+ output.set(headerBytes, PREFIX_LENGTH);
159
+ let offset = PREFIX_LENGTH + headerBytes.length;
160
+ for (const part of parts) {
161
+ output.set(part, offset);
162
+ offset += part.length;
163
+ }
164
+ return output;
165
+ }
166
+
167
+ export async function unwrapDataKey(header, keys) {
168
+ for (const entry of header.keys) {
169
+ const match = keys.find((candidate) => candidate.kid === entry.kid);
170
+ if (!match) continue;
171
+ try {
172
+ const raw = await subtle().decrypt(
173
+ {name: 'AES-GCM', iv: fromBase64(entry.iv), additionalData: wrapContext(entry.kid, header)},
174
+ await aesKey(match.key, ['decrypt']),
175
+ fromBase64(entry.wrapped),
176
+ );
177
+ return subtle().importKey('raw', raw, 'AES-GCM', false, ['decrypt']);
178
+ } catch {
179
+ throw new Error(`Bundle key ${entry.kid} could not unlock this file`);
180
+ }
181
+ }
182
+ throw new Error(`None of your bundle keys can open this file (it needs ${header.keys.map((entry) => entry.kid).join(' or ')})`);
183
+ }
184
+
185
+ export async function open(bytes, keys, expected = {}) {
186
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
187
+ const {header, headerBytes, payloadOffset} = parseEnvelope(view);
188
+ for (const field of ['appId', 'packageHash', 'kind', 'baseHash']) {
189
+ if (expected[field] !== undefined && header[field] !== expected[field]) throw new Error(`Envelope ${field} does not match`);
190
+ }
191
+ const dataKey = await unwrapDataKey(header, keys);
192
+ const aad = new Uint8Array(await subtle().digest('SHA-256', headerBytes));
193
+ const prefix = fromBase64(header.nonce);
194
+ const payload = view.subarray(payloadOffset);
195
+ const stride = SEGMENT_SIZE + TAG_LENGTH;
196
+ const segmentCount = Math.max(1, Math.ceil(payload.length / stride));
197
+ if (payload.length < TAG_LENGTH || (payload.length % stride !== 0 && payload.length % stride < TAG_LENGTH)) throw new Error('Envelope payload is truncated');
198
+ const parts = [];
199
+ for (let index = 0; index < segmentCount; index++) {
200
+ const segment = payload.subarray(index * stride, (index + 1) * stride);
201
+ const iv = segmentNonce(prefix, index, index === segmentCount - 1);
202
+ try {
203
+ parts.push(new Uint8Array(await subtle().decrypt({name: 'AES-GCM', iv, additionalData: aad}, dataKey, segment)));
204
+ } catch {
205
+ throw new Error('Envelope failed authentication: the file was altered, truncated or is for another release');
206
+ }
207
+ }
208
+ const output = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
209
+ let offset = 0;
210
+ for (const part of parts) {
211
+ output.set(part, offset);
212
+ offset += part.length;
213
+ }
214
+ return {header, plaintext: output};
215
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/pulsar-cli",
3
- "version": "1.4.0",
3
+ "version": "1.5.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": {
@@ -10,6 +10,9 @@
10
10
  "pulsar.js",
11
11
  "hash.js",
12
12
  "migrate.js",
13
+ "private.js",
14
+ "envelope.js",
15
+ "releaseCompare.js",
13
16
  "README.md",
14
17
  "LICENSE"
15
18
  ],
@@ -27,7 +30,7 @@
27
30
  "node": ">=18"
28
31
  },
29
32
  "scripts": {
30
- "test": "node test/hash.test.js && node test/security.test.js && node test/signing.test.js && node test/entry.test.js && node test/migrate.test.js"
33
+ "test": "node test/hash.test.js && node test/security.test.js && node test/signing.test.js && node test/entry.test.js && node test/migrate.test.js && node test/envelope.test.js && node test/private.test.js"
31
34
  },
32
35
  "keywords": [
33
36
  "cli",
package/private.js ADDED
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {api, die, getFlag, loadConfig, resolveApp} from './pulsar.js';
4
+ import {seal, open, parseKeys, generateKeyString, SEALED_CONTENT_TYPE} from './envelope.js';
5
+ import {compareManifests, compareModules, manifestParts} from './releaseCompare.js';
6
+
7
+ export async function loadBundleKeys(args) {
8
+ const keyFile = getFlag(args, 'bundle-key');
9
+ if (keyFile && !fs.existsSync(keyFile)) die(`no bundle key file at ${keyFile}`);
10
+ const keyString = keyFile ? fs.readFileSync(keyFile, 'utf8') : (process.env.PULSAR_BUNDLE_KEY || '').trim();
11
+ if (!keyString) return [];
12
+ try {
13
+ return await parseKeys(keyString);
14
+ } catch (err) {
15
+ die(`bundle key: ${err.message}`);
16
+ }
17
+ }
18
+
19
+ export async function privateContext(app, args, localKey) {
20
+ let settings;
21
+ try {
22
+ settings = await api(`/apps/${app.id}/private-bundles`, {optional: true});
23
+ } catch {
24
+ return null;
25
+ }
26
+ if (!settings || settings.mode === 'off') return null;
27
+ if (!localKey) die(`${app.name} uses private bundles, which need local signing: pass --private-key <file> or set PULSAR_SIGNING_KEY. Nothing was uploaded.`);
28
+ const plain = args.includes('--only-older-builds') ? 'older' : args.includes('--plain') ? 'all' : null;
29
+ if (plain && args.includes('--plain-copy-for-old-builds')) die('--plain-copy-for-old-builds goes with an encrypted release; drop it when releasing plain. Nothing was uploaded.');
30
+ const given = await loadBundleKeys(args);
31
+ const registered = new Set(settings.keys.map((entry) => entry.kid));
32
+ const keys = given.filter((key) => registered.has(key.kid));
33
+ if (plain) return {keys, readiness: settings.readiness, plain};
34
+ if (!given.length) die(`${app.name} uses private bundles: pass --bundle-key <file> or set PULSAR_BUNDLE_KEY, or release plain on purpose with --plain or --only-older-builds. Nothing was uploaded.`);
35
+ if (!keys.length) die(`none of your bundle keys (${given.map((key) => key.kid).join(', ')}) are registered for ${app.name}. Registered: ${[...registered].join(', ') || 'none'}`);
36
+ const missing = [...registered].filter((kid) => !keys.some((key) => key.kid === kid));
37
+ if (missing.length) console.log(`⚠ registered key ${missing.join(', ')} wasn't given, so builds holding only that key won't get this release`);
38
+ return {keys, readiness: settings.readiness, plain: null};
39
+ }
40
+
41
+ export function reportReach(context, {mandatory, plainCopy}) {
42
+ const {total = 0, ready = 0, windowDays = 30} = context.readiness || {};
43
+ const left = total - ready;
44
+ if (context.plain === 'all') {
45
+ console.log(`• plain release on purpose: reaches all ${total} devices seen in the last ${windowDays} days, and Pulsar can read it`);
46
+ return;
47
+ }
48
+ if (context.plain === 'older') {
49
+ console.log(`• plain release for older builds only: reaches the ${left} of ${total} devices that can't open private releases; Pulsar can read it. The ${ready} that can keep their encrypted release`);
50
+ if (mandatory && left === 0) die('every recent device can open private releases, so an older-builds-only release would reach nobody. Nothing was uploaded.');
51
+ return;
52
+ }
53
+ if (plainCopy) {
54
+ console.log(`• private release: ${ready} of ${total} devices seen in the last ${windowDays} days get the encrypted copy; ${left} get the plain copy for older builds, which Pulsar can read`);
55
+ return;
56
+ }
57
+ console.log(`• private release: reaches ${ready} of ${total} devices seen in the last ${windowDays} days; ${left} stay on their current bundle`);
58
+ if (mandatory && ready === 0) {
59
+ die('no device can open private releases yet, so a mandatory one would reach nobody. Ship store builds with SDK 1.8 and the bundle key, or add --plain-copy-for-old-builds. Nothing was uploaded.');
60
+ }
61
+ }
62
+
63
+ export const sealArtifact = (context, app, packageHash, plaintext, kind, extra = {}) =>
64
+ seal({plaintext: new Uint8Array(plaintext), keys: context.keys, appId: app.id, packageHash, kind, ...extra});
65
+
66
+ export async function openArtifact(keys, bytes, expected) {
67
+ return Buffer.from((await open(new Uint8Array(bytes), keys, expected)).plaintext);
68
+ }
69
+
70
+ export const sealedHeaders = {'content-type': SEALED_CONTENT_TYPE};
71
+
72
+ async function cmdBundleKeyCreate(args) {
73
+ const appName = getFlag(args, 'app');
74
+ if (!appName) die('Usage: pulsar bundle-key create --app <name> [--out <file>] [--platform ios|android]');
75
+ const app = await resolveApp(appName, getFlag(args, 'platform'));
76
+ const outFile = getFlag(args, 'out', `pulsar-bundle-key-${app.name.replace(/[^A-Za-z0-9._-]+/g, '-')}.txt`);
77
+ if (fs.existsSync(outFile)) die(`${outFile} already exists. Pick another --out; never overwrite a bundle key.`);
78
+ const keyString = await generateKeyString();
79
+ const [{kid}] = await parseKeys(keyString);
80
+ fs.writeFileSync(outFile, `${keyString}\n`, {mode: 0o600, flag: 'wx'});
81
+ console.log(`✓ wrote bundle key ${kid} to ${path.resolve(outFile)} (owner-only)`);
82
+ try {
83
+ await api(`/apps/${app.id}/private-bundles`, {method: 'PUT', body: {addKids: [kid]}, optional: true});
84
+ console.log(`✓ registered key id ${kid} with ${app.name}`);
85
+ } catch (err) {
86
+ console.log(`⚠ couldn't register the key id (${err.message}). An admin can add ${kid} under Private bundles in the dashboard.`);
87
+ }
88
+ console.log('\nBack this file up now (password manager or secrets vault). If it is lost, builds that carry only this key can\'t receive updates until a new store build ships.');
89
+ console.log('\nCI: set PULSAR_BUNDLE_KEY to the file\'s contents.');
90
+ console.log('\niOS Info.plist:');
91
+ console.log(` <key>CodePushBundleKeys</key>\n <string>${keyString}</string>`);
92
+ console.log('\nAndroid res/values/strings.xml:');
93
+ console.log(` <string moduleConfig="true" name="CodePushBundleKeys">${keyString}</string>`);
94
+ }
95
+
96
+ async function cmdBundleKeyId(args) {
97
+ const keys = await loadBundleKeys(args);
98
+ if (!keys.length) die('pass --bundle-key <file> or set PULSAR_BUNDLE_KEY');
99
+ for (const key of keys) console.log(key.kid);
100
+ }
101
+
102
+ export async function cmdBundleKey(args) {
103
+ const [sub, ...rest] = args;
104
+ if (sub === 'create') return cmdBundleKeyCreate(rest);
105
+ if (sub === 'id') return cmdBundleKeyId(rest);
106
+ die('Usage: pulsar bundle-key <create|id> [--app <name>] [--bundle-key <file>]');
107
+ }
108
+
109
+ async function fetchSealed(app, deployment, label, kind) {
110
+ const {apiUrl, token} = loadConfig();
111
+ const response = await fetch(`${apiUrl.replace(/\/$/, '')}/apps/${app.id}/deployments/${deployment}/releases/${label}/sealed/${kind}`, {headers: {authorization: `Bearer ${token}`}});
112
+ if (!response.ok) return null;
113
+ return new Uint8Array(await response.arrayBuffer());
114
+ }
115
+
116
+ export async function cmdChanges(args) {
117
+ const appName = getFlag(args, 'app');
118
+ const deployment = getFlag(args, 'deployment', 'Staging');
119
+ const label = getFlag(args, 'label');
120
+ if (!appName || !label) die('Usage: pulsar changes --app <name> --deployment <Staging|Production> --label <vN> [--base <vM>] [--bundle-key <file>]');
121
+ const app = await resolveApp(appName, getFlag(args, 'platform'));
122
+ const base = getFlag(args, 'base');
123
+ const summary = await api(`/apps/${app.id}/deployments/${deployment}/releases/${label}/changes${base ? `?base=${encodeURIComponent(base)}` : ''}`);
124
+ if (!summary.base) return console.log(`${label} has nothing earlier to compare with.`);
125
+ let files = summary.files;
126
+ let modules = summary.modules;
127
+ if (summary.sealed) {
128
+ const keys = await loadBundleKeys(args);
129
+ if (!keys.length) die('this release is private: pass --bundle-key <file> or set PULSAR_BUNDLE_KEY to unlock it here');
130
+ const releases = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
131
+ const hashOf = (wanted) => releases.find((entry) => entry.label === wanted)?.packageHash;
132
+ const unlock = async (wanted, kind) => {
133
+ const bytes = await fetchSealed(app, deployment, wanted, kind);
134
+ if (!bytes) return null;
135
+ return JSON.parse((await openArtifact(keys, bytes, {appId: app.id, packageHash: hashOf(wanted), kind})).toString('utf8'));
136
+ };
137
+ const [before, after] = (await Promise.all([unlock(summary.base, 'manifest'), unlock(summary.target, 'manifest')])).map(manifestParts);
138
+ files = before.files && after.files ? compareManifests(before.files, after.files) : null;
139
+ let beforeModules = before.modules;
140
+ let afterModules = after.modules;
141
+ if (!beforeModules || !afterModules) {
142
+ const [beforeMap, afterMap] = await Promise.all([unlock(summary.base, 'sourcemap'), unlock(summary.target, 'sourcemap')]);
143
+ beforeModules = beforeMap?.sources || null;
144
+ afterModules = afterMap?.sources || null;
145
+ }
146
+ modules = beforeModules && afterModules ? compareModules(beforeModules, afterModules) : null;
147
+ }
148
+ console.log(`${summary.target} compared with ${summary.base}${summary.sealed ? ' (unlocked on this machine)' : ''}`);
149
+ if (!files && !modules) return console.log('No file list or source map was uploaded for one of these releases.');
150
+ if (files) {
151
+ console.log(` files: ${files.counts.added} added, ${files.counts.modified} modified, ${files.counts.removed} removed, ${files.counts.unchanged} unchanged`);
152
+ for (const entry of files.added) console.log(` + ${entry.path}`);
153
+ for (const entry of files.modified) console.log(` ~ ${entry.path}`);
154
+ for (const entry of files.removed) console.log(` - ${entry.path}`);
155
+ }
156
+ if (modules) {
157
+ console.log(` modules: ${modules.counts.added} added, ${modules.counts.removed} removed`);
158
+ for (const name of modules.added) console.log(` + ${name}`);
159
+ for (const name of modules.removed) console.log(` - ${name}`);
160
+ }
161
+ }
package/pulsar.js CHANGED
@@ -9,6 +9,7 @@ import crypto from 'node:crypto';
9
9
  import {fileURLToPath} from 'node:url';
10
10
  import {computePackageHash, fileHashes} from './hash.js';
11
11
  import {migrate} from './migrate.js';
12
+ import {privateContext, reportReach, sealArtifact, openArtifact, sealedHeaders, cmdBundleKey, cmdChanges} from './private.js';
12
13
 
13
14
  // Code signing: sign the package hash into an RS256 JWT the client verifies against its
14
15
  // CodePushPublicKey (Info.plist). Both native SDKs read it from <update>/CodePush/.codepushrelease
@@ -42,14 +43,16 @@ function loadConfig() {
42
43
  return {
43
44
  apiUrl: process.env.PULSAR_API_URL || fromFile.apiUrl,
44
45
  token: process.env.PULSAR_TOKEN || fromFile.token,
46
+ deviceId: fromFile.deviceId,
45
47
  };
46
48
  }
47
49
 
48
50
  function saveConfig(config) {
49
51
  // The config holds a bearer token — keep the dir and file owner-only so other local users on a
50
52
  // shared host can't read the session.
53
+ const deviceId = config.deviceId || loadConfig().deviceId;
51
54
  fs.mkdirSync(CONFIG_DIR, {recursive: true, mode: 0o700});
52
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {mode: 0o600});
55
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify({...config, deviceId}, null, 2), {mode: 0o600});
53
56
  try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* best-effort on pre-existing files */ }
54
57
  }
55
58
 
@@ -347,8 +350,9 @@ async function cmdLogin(args) {
347
350
 
348
351
  const email = await prompt('Email: ');
349
352
  const password = await prompt('Password: ', true);
350
- const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, allowAnonymous: true});
351
- saveConfig({apiUrl, token});
353
+ const deviceId = loadConfig().deviceId || crypto.randomBytes(16).toString('hex');
354
+ const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, headers: {'x-device-id': deviceId}, allowAnonymous: true});
355
+ saveConfig({apiUrl, token, deviceId});
352
356
  console.log(`✓ logged in as ${user.email} (${user.platformRole})`);
353
357
  }
354
358
 
@@ -389,9 +393,14 @@ async function signForApp(app, deployment, contentsDir, packageHash, localKey) {
389
393
 
390
394
  // Sign and push an already-built bundle to one app's deployment, then generate and upload a
391
395
  // differential patch against whatever that deployment was serving.
392
- async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath} = {}) {
396
+ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath, reachReported = false} = {}) {
397
+ const privateBundles = await privateContext(app, args, localKey);
398
+ const sealing = Boolean(privateBundles) && !privateBundles.plain;
399
+ const plainCopy = sealing && args.includes('--plain-copy-for-old-builds');
400
+ if (privateBundles && !reachReported) reportReach(privateBundles, {mandatory: args.includes('--mandatory'), plainCopy});
393
401
  await signForApp(app, deployment, contentsDir, packageHash, localKey);
394
- const zip = fs.readFileSync(zipContents(contentsDir));
402
+ const plainZip = fs.readFileSync(zipContents(contentsDir));
403
+ const zip = sealing ? Buffer.from(await sealArtifact(privateBundles, app, packageHash, plainZip, 'full')) : plainZip;
395
404
  let prevLatest = null;
396
405
  try {
397
406
  const existing = await api(`/apps/${app.id}/deployments/${deployment}/releases`, {optional: true});
@@ -423,7 +432,11 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
423
432
  body: zip,
424
433
  onUploadProgress: uploadProgress(`uploading ${(zip.length / 1e6).toFixed(1)}MB bundle`),
425
434
  headers: {
426
- 'content-type': 'application/zip',
435
+ ...(sealing
436
+ ? {...sealedHeaders, 'x-encrypted': '1', 'x-key-ids': privateBundles.keys.map((key) => key.kid).join(','), 'x-release-signature': fs.readFileSync(path.join(contentsDir, SIGNATURE_PATH), 'utf8')}
437
+ : {'content-type': 'application/zip'}),
438
+ ...(privateBundles?.plain === 'all' ? {'x-plain': '1'} : {}),
439
+ ...(privateBundles?.plain === 'older' ? {'x-audience': 'older-builds'} : {}),
427
440
  'x-app-version': getFlag(args, 'target'),
428
441
  'x-package-hash': packageHash,
429
442
  'x-is-mandatory': String(args.includes('--mandatory')),
@@ -446,7 +459,18 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
446
459
  release.targetFromLabels?.length ? `devices on ${release.targetFromLabels.join('/')}` : null,
447
460
  ].filter(Boolean);
448
461
  const reach = scopes.length ? scopes.join(' + ') : `${release.rollout}% of devices`;
449
- console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}`);
462
+ const privacy = release.encrypted ? ' (private)' : release.olderBuildsOnly ? ' (plain, older builds only)' : privateBundles ? ' (plain)' : '';
463
+ console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}${privacy}`);
464
+ if (plainCopy) {
465
+ await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/plain-copy`, {
466
+ method: 'POST',
467
+ raw: true,
468
+ body: plainZip,
469
+ onUploadProgress: uploadProgress(`uploading ${(plainZip.length / 1e6).toFixed(1)}MB plain copy for older builds`),
470
+ headers: {'content-type': 'application/zip'},
471
+ });
472
+ console.log('• plain copy uploaded: older builds get this release too, and Pulsar can read that copy');
473
+ }
450
474
  if (release.approval && !release.approval.approvedAt) {
451
475
  console.log(`⏸ waiting for approval: ${deployment} needs a second admin or owner to approve ${release.label} in the dashboard before devices get it`);
452
476
  }
@@ -457,22 +481,35 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
457
481
  if (release.rolloutRamp) console.log(` gradual rollout — ${release.rollout}% to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h`);
458
482
 
459
483
  try {
460
- await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/manifest`, {method: 'POST', body: {files: fileManifest(contentsDir)}, optional: true});
484
+ const files = {files: fileManifest(contentsDir)};
485
+ const sealedManifest = () => {
486
+ let modules = null;
487
+ try {
488
+ modules = sourcemapPath && fs.existsSync(sourcemapPath) ? JSON.parse(fs.readFileSync(sourcemapPath, 'utf8')).sources || null : null;
489
+ } catch {
490
+ modules = null;
491
+ }
492
+ return Buffer.from(JSON.stringify(modules ? {...files, modules} : files));
493
+ };
494
+ await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/manifest`, sealing
495
+ ? {method: 'POST', raw: true, optional: true, body: await sealArtifact(privateBundles, app, packageHash, sealedManifest(), 'manifest'), headers: sealedHeaders}
496
+ : {method: 'POST', body: files, optional: true});
461
497
  } catch (err) {
462
498
  console.log(`• file list not recorded: ${err.message}`);
463
499
  }
464
500
  if (sourcemapPath && fs.existsSync(sourcemapPath)) {
465
501
  try {
466
- const map = fs.readFileSync(sourcemapPath);
502
+ const plainMap = fs.readFileSync(sourcemapPath);
503
+ const map = sealing ? Buffer.from(await sealArtifact(privateBundles, app, packageHash, plainMap, 'sourcemap')) : plainMap;
467
504
  await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/sourcemap`, {
468
505
  method: 'POST',
469
506
  raw: true,
470
507
  optional: true,
471
508
  body: map,
472
509
  onUploadProgress: uploadProgress(`uploading ${(map.length / 1e6).toFixed(1)}MB source map`),
473
- headers: {'content-type': 'application/json'},
510
+ headers: sealing ? sealedHeaders : {'content-type': 'application/json'},
474
511
  });
475
- console.log('• source map uploaded: crash stacks for this release will show your source files');
512
+ console.log(sealing ? '• source map uploaded, encrypted: unlock it in the dashboard or with pulsar changes' : '• source map uploaded: crash stacks for this release will show your source files');
476
513
  } catch (err) {
477
514
  console.log(`• source map not uploaded: ${err.message}`);
478
515
  }
@@ -481,16 +518,24 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
481
518
  if (!args.includes('--no-diff') && prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
482
519
  try {
483
520
  const {apiUrl} = loadConfig();
484
- const baseZip = await fetchBlob(apiUrl, app.id, `${prevLatest.packageHash}.zip`);
521
+ const baseFile = prevLatest.encrypted && prevLatest.blobKey ? prevLatest.blobKey.slice(app.id.length + 1) : `${prevLatest.packageHash}.zip`;
522
+ let baseZip = await fetchBlob(apiUrl, app.id, baseFile);
523
+ if (prevLatest.encrypted) {
524
+ if (!privateBundles?.keys.length) throw new Error(`${prevLatest.label} is private and no bundle key was given`);
525
+ baseZip = await openArtifact(privateBundles.keys, baseZip, {appId: app.id, packageHash: prevLatest.packageHash, kind: 'full'});
526
+ }
485
527
  const diffDir = await buildDiffPackage(baseZip, contentsDir);
486
- const diffZip = fs.readFileSync(zipContents(diffDir));
528
+ const plainDiff = fs.readFileSync(zipContents(diffDir));
529
+ const diffZip = sealing
530
+ ? Buffer.from(await sealArtifact(privateBundles, app, packageHash, plainDiff, 'diff', {baseHash: prevLatest.packageHash}))
531
+ : plainDiff;
487
532
  await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/diff`, {
488
533
  method: 'POST',
489
534
  raw: true,
490
535
  optional: true,
491
536
  body: diffZip,
492
537
  onUploadProgress: uploadProgress(`uploading ${(diffZip.length / 1e6).toFixed(1)}MB diff`),
493
- headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
538
+ headers: {...(sealing ? sealedHeaders : {'content-type': 'application/zip'}), 'x-base-hash': prevLatest.packageHash},
494
539
  });
495
540
  const saved = Math.round((1 - diffZip.length / zip.length) * 100);
496
541
  console.log(`• diff vs ${prevLatest.label}: ${(diffZip.length / 1e3).toFixed(0)} KB (${saved}% smaller)`);
@@ -503,11 +548,23 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
503
548
 
504
549
  // Build the JS bundle once and return what every target app's release is made from. A local key
505
550
  // (--private-key <file>, or PULSAR_SIGNING_KEY holding the key itself) overrides Pulsar's signing.
506
- function buildOnce(args) {
507
- const platform = getFlag(args, 'platform', 'ios');
551
+ function localSigningKey(args) {
508
552
  const privateKeyPath = getFlag(args, 'private-key');
509
553
  if (privateKeyPath && !fs.existsSync(privateKeyPath)) die(`no signing key at ${privateKeyPath} — drop --private-key to have Pulsar sign`);
510
- const localKey = privateKeyPath ? fs.readFileSync(privateKeyPath, 'utf8') : (process.env.PULSAR_SIGNING_KEY || '').trim();
554
+ return privateKeyPath ? fs.readFileSync(privateKeyPath, 'utf8') : (process.env.PULSAR_SIGNING_KEY || '').trim();
555
+ }
556
+
557
+ async function checkPrivateBeforeBuild(apps, args) {
558
+ const localKey = localSigningKey(args);
559
+ for (const app of apps) {
560
+ const context = await privateContext(app, args, localKey);
561
+ if (context) reportReach(context, {mandatory: args.includes('--mandatory'), plainCopy: args.includes('--plain-copy-for-old-builds')});
562
+ }
563
+ }
564
+
565
+ function buildOnce(args) {
566
+ const platform = getFlag(args, 'platform', 'ios');
567
+ const localKey = localSigningKey(args);
511
568
  const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
512
569
  const sourcemapPath = args.includes('--no-sourcemap') ? null : `${contentsDir}.map`;
513
570
  bundle(platform, contentsDir, sourcemapPath);
@@ -518,14 +575,15 @@ function buildOnce(args) {
518
575
 
519
576
  async function cmdRelease(args) {
520
577
  const [nameOrId, deployment = 'Staging'] = args;
521
- if (!nameOrId) die('Usage: pulsar release <app> <Staging|Production> --platform ios|android --target <ver> [--mandatory] [--disabled] [--rollout N] [--schedule +6h|ISO] [--ramp 100@48] [--from v6,ota,binary] [--devices id1,id2,...] [--device-models model1;model2] [--users u1,u2] [--private-key key.pem] [--no-diff] [--no-sourcemap] [--max-growth <percent>] [--description "..."]');
578
+ if (!nameOrId) die('Usage: pulsar release <app> <Staging|Production> --platform ios|android --target <ver> [--mandatory] [--disabled] [--rollout N] [--schedule +6h|ISO] [--ramp 100@48] [--from v6,ota,binary] [--devices id1,id2,...] [--device-models model1;model2] [--users u1,u2] [--private-key key.pem] [--bundle-key key.txt] [--plain-copy-for-old-builds | --plain | --only-older-builds] [--no-diff] [--no-sourcemap] [--max-growth <percent>] [--description "..."]');
522
579
  if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
523
580
  const app = await resolveApp(nameOrId, getFlag(args, 'platform', 'ios'));
524
581
  if (app.os !== getFlag(args, 'platform', 'ios')) {
525
582
  console.log(`• note: resolved app "${app.name}" is ${app.os}; bundling ${getFlag(args, 'platform', 'ios')}`);
526
583
  }
584
+ await checkPrivateBeforeBuild([app], args);
527
585
  const {contentsDir, packageHash, localKey, sourcemapPath} = buildOnce(args);
528
- await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath});
586
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath, reachReported: true});
529
587
  }
530
588
 
531
589
  // Release the same built bundle to several apps at once (shared-codebase / white-label fleets).
@@ -550,11 +608,12 @@ async function cmdReleaseTrain(args) {
550
608
  apps = apps.filter((app) => app.os === platform);
551
609
  if (!apps.length) die(`No ${platform} apps matched.`);
552
610
 
611
+ await checkPrivateBeforeBuild(apps, args);
553
612
  const {contentsDir, packageHash, localKey, sourcemapPath} = buildOnce(args);
554
613
  console.log(`• release train → ${apps.length} app(s): ${apps.map((app) => app.name).join(', ')}`);
555
614
  for (const app of apps) {
556
615
  try {
557
- await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath});
616
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath, reachReported: true});
558
617
  } catch (err) {
559
618
  console.log(`✗ ${app.name}: ${err.message}`);
560
619
  }
@@ -601,7 +660,8 @@ async function cmdHistory(args) {
601
660
  const releases = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
602
661
  if (!releases.length) return console.log('(no releases)');
603
662
  for (const release of [...releases].reverse()) {
604
- const flags = `${release.isMandatory ? ' [mandatory]' : ''}${release.isDisabled ? ' [disabled]' : ''}`;
663
+ const privacy = release.encrypted ? ` [private${release.plainCopy ? ' + plain copy' : ''}]` : release.olderBuildsOnly ? ' [plain, older builds only]' : release.plainOnPurpose ? ' [plain]' : '';
664
+ const flags = `${release.isMandatory ? ' [mandatory]' : ''}${release.isDisabled ? ' [disabled]' : ''}${privacy}`;
605
665
  const rollout = release.currentRollout ?? release.rollout;
606
666
  const state = release.state && release.state !== 'live' ? ` [${release.state}]` : '';
607
667
  const when = release.startsAt ? ` starts ${new Date(release.startsAt).toLocaleString()}` : '';
@@ -683,7 +743,7 @@ async function cmdKeys(args) {
683
743
  console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
684
744
  }
685
745
 
686
- export {saveConfig, browserLogin, signCodePushJWT, SIGNATURE_PATH, api, die, getFlag, loadConfig, pushBuiltRelease, splitList};
746
+ export {saveConfig, browserLogin, signCodePushJWT, SIGNATURE_PATH, api, die, getFlag, loadConfig, pushBuiltRelease, splitList, resolveApp};
687
747
 
688
748
  // Only dispatch when run as the CLI — importing the module (e.g. from tests) must not execute a command.
689
749
  // Compare real paths: a global install runs through a bin symlink, so argv[1] is not this file's path.
@@ -709,6 +769,8 @@ if (invokedAsCli()) {
709
769
  patch: cmdPatch,
710
770
  keygen: cmdKeygen,
711
771
  keys: cmdKeys,
772
+ 'bundle-key': cmdBundleKey,
773
+ changes: cmdChanges,
712
774
  migrate,
713
775
  };
714
776
 
@@ -719,7 +781,7 @@ if (invokedAsCli()) {
719
781
  }
720
782
 
721
783
  (commands[command] || (() => {
722
- console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen|migrate>');
784
+ console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen|bundle-key|changes|migrate>');
723
785
  process.exit(command ? 1 : 0);
724
786
  }))(rest);
725
787
  }
@@ -0,0 +1,45 @@
1
+ const LIST_LIMIT = 200;
2
+
3
+ export function compareManifests(before, after) {
4
+ const added = [];
5
+ const removed = [];
6
+ const modified = [];
7
+ let unchanged = 0;
8
+ for (const [filePath, entry] of Object.entries(after)) {
9
+ const previous = before[filePath];
10
+ if (!previous) added.push({path: filePath, size: entry.size});
11
+ else if (previous.hash !== entry.hash) modified.push({path: filePath, before: previous.size, after: entry.size});
12
+ else unchanged += 1;
13
+ }
14
+ for (const [filePath, entry] of Object.entries(before)) if (!after[filePath]) removed.push({path: filePath, size: entry.size});
15
+ const total = (manifest) => Object.values(manifest).reduce((sum, entry) => sum + entry.size, 0);
16
+ const bySize = (left, right) => (right.size ?? Math.abs(right.after - right.before)) - (left.size ?? Math.abs(left.after - left.before));
17
+ return {
18
+ added: added.sort(bySize).slice(0, LIST_LIMIT),
19
+ removed: removed.sort(bySize).slice(0, LIST_LIMIT),
20
+ modified: modified.sort(bySize).slice(0, LIST_LIMIT),
21
+ counts: {added: added.length, removed: removed.length, modified: modified.length, unchanged},
22
+ size: {before: total(before), after: total(after)},
23
+ };
24
+ }
25
+
26
+ export const moduleName = (source) => {
27
+ const text = String(source || '');
28
+ const match = /(?:^|\/)(node_modules\/.*)$/.exec(text) || /(?:^|\/)(src\/.*)$/.exec(text);
29
+ return match ? match[1] : text.replace(/^.*\/(?=[^/]+\/[^/]+$)/, '');
30
+ };
31
+
32
+ export function compareModules(beforeSources, afterSources) {
33
+ const before = new Set((beforeSources || []).map(moduleName));
34
+ const after = new Set((afterSources || []).map(moduleName));
35
+ const added = [...after].filter((name) => !before.has(name));
36
+ const removed = [...before].filter((name) => !after.has(name));
37
+ return {added: added.slice(0, LIST_LIMIT), removed: removed.slice(0, LIST_LIMIT), counts: {added: added.length, removed: removed.length}};
38
+ }
39
+
40
+ export function manifestParts(document) {
41
+ if (document?.files && typeof document.files === 'object' && !Array.isArray(document.files)) {
42
+ return {files: document.files, modules: Array.isArray(document.modules) ? document.modules : null};
43
+ }
44
+ return {files: document || null, modules: null};
45
+ }