@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 +78 -1
- package/envelope.js +215 -0
- package/package.json +5 -2
- package/private.js +161 -0
- package/pulsar.js +79 -20
- package/releaseCompare.js +45 -0
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|