@novacraft-engineering/pulsar-cli 1.4.1 → 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.1",
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
@@ -392,9 +393,14 @@ async function signForApp(app, deployment, contentsDir, packageHash, localKey) {
392
393
 
393
394
  // Sign and push an already-built bundle to one app's deployment, then generate and upload a
394
395
  // differential patch against whatever that deployment was serving.
395
- 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});
396
401
  await signForApp(app, deployment, contentsDir, packageHash, localKey);
397
- 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;
398
404
  let prevLatest = null;
399
405
  try {
400
406
  const existing = await api(`/apps/${app.id}/deployments/${deployment}/releases`, {optional: true});
@@ -426,7 +432,11 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
426
432
  body: zip,
427
433
  onUploadProgress: uploadProgress(`uploading ${(zip.length / 1e6).toFixed(1)}MB bundle`),
428
434
  headers: {
429
- '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'} : {}),
430
440
  'x-app-version': getFlag(args, 'target'),
431
441
  'x-package-hash': packageHash,
432
442
  'x-is-mandatory': String(args.includes('--mandatory')),
@@ -449,7 +459,18 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
449
459
  release.targetFromLabels?.length ? `devices on ${release.targetFromLabels.join('/')}` : null,
450
460
  ].filter(Boolean);
451
461
  const reach = scopes.length ? scopes.join(' + ') : `${release.rollout}% of devices`;
452
- 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
+ }
453
474
  if (release.approval && !release.approval.approvedAt) {
454
475
  console.log(`⏸ waiting for approval: ${deployment} needs a second admin or owner to approve ${release.label} in the dashboard before devices get it`);
455
476
  }
@@ -460,22 +481,35 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
460
481
  if (release.rolloutRamp) console.log(` gradual rollout — ${release.rollout}% to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h`);
461
482
 
462
483
  try {
463
- 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});
464
497
  } catch (err) {
465
498
  console.log(`• file list not recorded: ${err.message}`);
466
499
  }
467
500
  if (sourcemapPath && fs.existsSync(sourcemapPath)) {
468
501
  try {
469
- 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;
470
504
  await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/sourcemap`, {
471
505
  method: 'POST',
472
506
  raw: true,
473
507
  optional: true,
474
508
  body: map,
475
509
  onUploadProgress: uploadProgress(`uploading ${(map.length / 1e6).toFixed(1)}MB source map`),
476
- headers: {'content-type': 'application/json'},
510
+ headers: sealing ? sealedHeaders : {'content-type': 'application/json'},
477
511
  });
478
- 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');
479
513
  } catch (err) {
480
514
  console.log(`• source map not uploaded: ${err.message}`);
481
515
  }
@@ -484,16 +518,24 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
484
518
  if (!args.includes('--no-diff') && prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
485
519
  try {
486
520
  const {apiUrl} = loadConfig();
487
- 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
+ }
488
527
  const diffDir = await buildDiffPackage(baseZip, contentsDir);
489
- 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;
490
532
  await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/diff`, {
491
533
  method: 'POST',
492
534
  raw: true,
493
535
  optional: true,
494
536
  body: diffZip,
495
537
  onUploadProgress: uploadProgress(`uploading ${(diffZip.length / 1e6).toFixed(1)}MB diff`),
496
- headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
538
+ headers: {...(sealing ? sealedHeaders : {'content-type': 'application/zip'}), 'x-base-hash': prevLatest.packageHash},
497
539
  });
498
540
  const saved = Math.round((1 - diffZip.length / zip.length) * 100);
499
541
  console.log(`• diff vs ${prevLatest.label}: ${(diffZip.length / 1e3).toFixed(0)} KB (${saved}% smaller)`);
@@ -506,11 +548,23 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
506
548
 
507
549
  // Build the JS bundle once and return what every target app's release is made from. A local key
508
550
  // (--private-key <file>, or PULSAR_SIGNING_KEY holding the key itself) overrides Pulsar's signing.
509
- function buildOnce(args) {
510
- const platform = getFlag(args, 'platform', 'ios');
551
+ function localSigningKey(args) {
511
552
  const privateKeyPath = getFlag(args, 'private-key');
512
553
  if (privateKeyPath && !fs.existsSync(privateKeyPath)) die(`no signing key at ${privateKeyPath} — drop --private-key to have Pulsar sign`);
513
- 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);
514
568
  const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
515
569
  const sourcemapPath = args.includes('--no-sourcemap') ? null : `${contentsDir}.map`;
516
570
  bundle(platform, contentsDir, sourcemapPath);
@@ -521,14 +575,15 @@ function buildOnce(args) {
521
575
 
522
576
  async function cmdRelease(args) {
523
577
  const [nameOrId, deployment = 'Staging'] = args;
524
- 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 "..."]');
525
579
  if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
526
580
  const app = await resolveApp(nameOrId, getFlag(args, 'platform', 'ios'));
527
581
  if (app.os !== getFlag(args, 'platform', 'ios')) {
528
582
  console.log(`• note: resolved app "${app.name}" is ${app.os}; bundling ${getFlag(args, 'platform', 'ios')}`);
529
583
  }
584
+ await checkPrivateBeforeBuild([app], args);
530
585
  const {contentsDir, packageHash, localKey, sourcemapPath} = buildOnce(args);
531
- await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath});
586
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath, reachReported: true});
532
587
  }
533
588
 
534
589
  // Release the same built bundle to several apps at once (shared-codebase / white-label fleets).
@@ -553,11 +608,12 @@ async function cmdReleaseTrain(args) {
553
608
  apps = apps.filter((app) => app.os === platform);
554
609
  if (!apps.length) die(`No ${platform} apps matched.`);
555
610
 
611
+ await checkPrivateBeforeBuild(apps, args);
556
612
  const {contentsDir, packageHash, localKey, sourcemapPath} = buildOnce(args);
557
613
  console.log(`• release train → ${apps.length} app(s): ${apps.map((app) => app.name).join(', ')}`);
558
614
  for (const app of apps) {
559
615
  try {
560
- await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath});
616
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath, reachReported: true});
561
617
  } catch (err) {
562
618
  console.log(`✗ ${app.name}: ${err.message}`);
563
619
  }
@@ -604,7 +660,8 @@ async function cmdHistory(args) {
604
660
  const releases = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
605
661
  if (!releases.length) return console.log('(no releases)');
606
662
  for (const release of [...releases].reverse()) {
607
- 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}`;
608
665
  const rollout = release.currentRollout ?? release.rollout;
609
666
  const state = release.state && release.state !== 'live' ? ` [${release.state}]` : '';
610
667
  const when = release.startsAt ? ` starts ${new Date(release.startsAt).toLocaleString()}` : '';
@@ -686,7 +743,7 @@ async function cmdKeys(args) {
686
743
  console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
687
744
  }
688
745
 
689
- 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};
690
747
 
691
748
  // Only dispatch when run as the CLI — importing the module (e.g. from tests) must not execute a command.
692
749
  // Compare real paths: a global install runs through a bin symlink, so argv[1] is not this file's path.
@@ -712,6 +769,8 @@ if (invokedAsCli()) {
712
769
  patch: cmdPatch,
713
770
  keygen: cmdKeygen,
714
771
  keys: cmdKeys,
772
+ 'bundle-key': cmdBundleKey,
773
+ changes: cmdChanges,
715
774
  migrate,
716
775
  };
717
776
 
@@ -722,7 +781,7 @@ if (invokedAsCli()) {
722
781
  }
723
782
 
724
783
  (commands[command] || (() => {
725
- 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>');
726
785
  process.exit(command ? 1 : 0);
727
786
  }))(rest);
728
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
+ }