@novacraft-engineering/pulsar-cli 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -1
- package/envelope.js +215 -0
- package/package.json +5 -2
- package/private.js +161 -0
- package/pulsar.js +85 -23
- 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
|
|
@@ -42,14 +43,16 @@ function loadConfig() {
|
|
|
42
43
|
return {
|
|
43
44
|
apiUrl: process.env.PULSAR_API_URL || fromFile.apiUrl,
|
|
44
45
|
token: process.env.PULSAR_TOKEN || fromFile.token,
|
|
46
|
+
deviceId: fromFile.deviceId,
|
|
45
47
|
};
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
function saveConfig(config) {
|
|
49
51
|
// The config holds a bearer token — keep the dir and file owner-only so other local users on a
|
|
50
52
|
// shared host can't read the session.
|
|
53
|
+
const deviceId = config.deviceId || loadConfig().deviceId;
|
|
51
54
|
fs.mkdirSync(CONFIG_DIR, {recursive: true, mode: 0o700});
|
|
52
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {mode: 0o600});
|
|
55
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify({...config, deviceId}, null, 2), {mode: 0o600});
|
|
53
56
|
try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* best-effort on pre-existing files */ }
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -347,8 +350,9 @@ async function cmdLogin(args) {
|
|
|
347
350
|
|
|
348
351
|
const email = await prompt('Email: ');
|
|
349
352
|
const password = await prompt('Password: ', true);
|
|
350
|
-
const
|
|
351
|
-
|
|
353
|
+
const deviceId = loadConfig().deviceId || crypto.randomBytes(16).toString('hex');
|
|
354
|
+
const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, headers: {'x-device-id': deviceId}, allowAnonymous: true});
|
|
355
|
+
saveConfig({apiUrl, token, deviceId});
|
|
352
356
|
console.log(`✓ logged in as ${user.email} (${user.platformRole})`);
|
|
353
357
|
}
|
|
354
358
|
|
|
@@ -389,9 +393,14 @@ async function signForApp(app, deployment, contentsDir, packageHash, localKey) {
|
|
|
389
393
|
|
|
390
394
|
// Sign and push an already-built bundle to one app's deployment, then generate and upload a
|
|
391
395
|
// differential patch against whatever that deployment was serving.
|
|
392
|
-
async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath} = {}) {
|
|
396
|
+
async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath, reachReported = false} = {}) {
|
|
397
|
+
const privateBundles = await privateContext(app, args, localKey);
|
|
398
|
+
const sealing = Boolean(privateBundles) && !privateBundles.plain;
|
|
399
|
+
const plainCopy = sealing && args.includes('--plain-copy-for-old-builds');
|
|
400
|
+
if (privateBundles && !reachReported) reportReach(privateBundles, {mandatory: args.includes('--mandatory'), plainCopy});
|
|
393
401
|
await signForApp(app, deployment, contentsDir, packageHash, localKey);
|
|
394
|
-
const
|
|
402
|
+
const plainZip = fs.readFileSync(zipContents(contentsDir));
|
|
403
|
+
const zip = sealing ? Buffer.from(await sealArtifact(privateBundles, app, packageHash, plainZip, 'full')) : plainZip;
|
|
395
404
|
let prevLatest = null;
|
|
396
405
|
try {
|
|
397
406
|
const existing = await api(`/apps/${app.id}/deployments/${deployment}/releases`, {optional: true});
|
|
@@ -423,7 +432,11 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
423
432
|
body: zip,
|
|
424
433
|
onUploadProgress: uploadProgress(`uploading ${(zip.length / 1e6).toFixed(1)}MB bundle`),
|
|
425
434
|
headers: {
|
|
426
|
-
|
|
435
|
+
...(sealing
|
|
436
|
+
? {...sealedHeaders, 'x-encrypted': '1', 'x-key-ids': privateBundles.keys.map((key) => key.kid).join(','), 'x-release-signature': fs.readFileSync(path.join(contentsDir, SIGNATURE_PATH), 'utf8')}
|
|
437
|
+
: {'content-type': 'application/zip'}),
|
|
438
|
+
...(privateBundles?.plain === 'all' ? {'x-plain': '1'} : {}),
|
|
439
|
+
...(privateBundles?.plain === 'older' ? {'x-audience': 'older-builds'} : {}),
|
|
427
440
|
'x-app-version': getFlag(args, 'target'),
|
|
428
441
|
'x-package-hash': packageHash,
|
|
429
442
|
'x-is-mandatory': String(args.includes('--mandatory')),
|
|
@@ -446,7 +459,18 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
446
459
|
release.targetFromLabels?.length ? `devices on ${release.targetFromLabels.join('/')}` : null,
|
|
447
460
|
].filter(Boolean);
|
|
448
461
|
const reach = scopes.length ? scopes.join(' + ') : `${release.rollout}% of devices`;
|
|
449
|
-
|
|
462
|
+
const privacy = release.encrypted ? ' (private)' : release.olderBuildsOnly ? ' (plain, older builds only)' : privateBundles ? ' (plain)' : '';
|
|
463
|
+
console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}${privacy}`);
|
|
464
|
+
if (plainCopy) {
|
|
465
|
+
await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/plain-copy`, {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
raw: true,
|
|
468
|
+
body: plainZip,
|
|
469
|
+
onUploadProgress: uploadProgress(`uploading ${(plainZip.length / 1e6).toFixed(1)}MB plain copy for older builds`),
|
|
470
|
+
headers: {'content-type': 'application/zip'},
|
|
471
|
+
});
|
|
472
|
+
console.log('• plain copy uploaded: older builds get this release too, and Pulsar can read that copy');
|
|
473
|
+
}
|
|
450
474
|
if (release.approval && !release.approval.approvedAt) {
|
|
451
475
|
console.log(`⏸ waiting for approval: ${deployment} needs a second admin or owner to approve ${release.label} in the dashboard before devices get it`);
|
|
452
476
|
}
|
|
@@ -457,22 +481,35 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
457
481
|
if (release.rolloutRamp) console.log(` gradual rollout — ${release.rollout}% to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h`);
|
|
458
482
|
|
|
459
483
|
try {
|
|
460
|
-
|
|
484
|
+
const files = {files: fileManifest(contentsDir)};
|
|
485
|
+
const sealedManifest = () => {
|
|
486
|
+
let modules = null;
|
|
487
|
+
try {
|
|
488
|
+
modules = sourcemapPath && fs.existsSync(sourcemapPath) ? JSON.parse(fs.readFileSync(sourcemapPath, 'utf8')).sources || null : null;
|
|
489
|
+
} catch {
|
|
490
|
+
modules = null;
|
|
491
|
+
}
|
|
492
|
+
return Buffer.from(JSON.stringify(modules ? {...files, modules} : files));
|
|
493
|
+
};
|
|
494
|
+
await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/manifest`, sealing
|
|
495
|
+
? {method: 'POST', raw: true, optional: true, body: await sealArtifact(privateBundles, app, packageHash, sealedManifest(), 'manifest'), headers: sealedHeaders}
|
|
496
|
+
: {method: 'POST', body: files, optional: true});
|
|
461
497
|
} catch (err) {
|
|
462
498
|
console.log(`• file list not recorded: ${err.message}`);
|
|
463
499
|
}
|
|
464
500
|
if (sourcemapPath && fs.existsSync(sourcemapPath)) {
|
|
465
501
|
try {
|
|
466
|
-
const
|
|
502
|
+
const plainMap = fs.readFileSync(sourcemapPath);
|
|
503
|
+
const map = sealing ? Buffer.from(await sealArtifact(privateBundles, app, packageHash, plainMap, 'sourcemap')) : plainMap;
|
|
467
504
|
await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/sourcemap`, {
|
|
468
505
|
method: 'POST',
|
|
469
506
|
raw: true,
|
|
470
507
|
optional: true,
|
|
471
508
|
body: map,
|
|
472
509
|
onUploadProgress: uploadProgress(`uploading ${(map.length / 1e6).toFixed(1)}MB source map`),
|
|
473
|
-
headers: {'content-type': 'application/json'},
|
|
510
|
+
headers: sealing ? sealedHeaders : {'content-type': 'application/json'},
|
|
474
511
|
});
|
|
475
|
-
console.log('• source map uploaded: crash stacks for this release will show your source files');
|
|
512
|
+
console.log(sealing ? '• source map uploaded, encrypted: unlock it in the dashboard or with pulsar changes' : '• source map uploaded: crash stacks for this release will show your source files');
|
|
476
513
|
} catch (err) {
|
|
477
514
|
console.log(`• source map not uploaded: ${err.message}`);
|
|
478
515
|
}
|
|
@@ -481,16 +518,24 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
481
518
|
if (!args.includes('--no-diff') && prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
|
|
482
519
|
try {
|
|
483
520
|
const {apiUrl} = loadConfig();
|
|
484
|
-
const
|
|
521
|
+
const baseFile = prevLatest.encrypted && prevLatest.blobKey ? prevLatest.blobKey.slice(app.id.length + 1) : `${prevLatest.packageHash}.zip`;
|
|
522
|
+
let baseZip = await fetchBlob(apiUrl, app.id, baseFile);
|
|
523
|
+
if (prevLatest.encrypted) {
|
|
524
|
+
if (!privateBundles?.keys.length) throw new Error(`${prevLatest.label} is private and no bundle key was given`);
|
|
525
|
+
baseZip = await openArtifact(privateBundles.keys, baseZip, {appId: app.id, packageHash: prevLatest.packageHash, kind: 'full'});
|
|
526
|
+
}
|
|
485
527
|
const diffDir = await buildDiffPackage(baseZip, contentsDir);
|
|
486
|
-
const
|
|
528
|
+
const plainDiff = fs.readFileSync(zipContents(diffDir));
|
|
529
|
+
const diffZip = sealing
|
|
530
|
+
? Buffer.from(await sealArtifact(privateBundles, app, packageHash, plainDiff, 'diff', {baseHash: prevLatest.packageHash}))
|
|
531
|
+
: plainDiff;
|
|
487
532
|
await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/diff`, {
|
|
488
533
|
method: 'POST',
|
|
489
534
|
raw: true,
|
|
490
535
|
optional: true,
|
|
491
536
|
body: diffZip,
|
|
492
537
|
onUploadProgress: uploadProgress(`uploading ${(diffZip.length / 1e6).toFixed(1)}MB diff`),
|
|
493
|
-
headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
|
|
538
|
+
headers: {...(sealing ? sealedHeaders : {'content-type': 'application/zip'}), 'x-base-hash': prevLatest.packageHash},
|
|
494
539
|
});
|
|
495
540
|
const saved = Math.round((1 - diffZip.length / zip.length) * 100);
|
|
496
541
|
console.log(`• diff vs ${prevLatest.label}: ${(diffZip.length / 1e3).toFixed(0)} KB (${saved}% smaller)`);
|
|
@@ -503,11 +548,23 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
503
548
|
|
|
504
549
|
// Build the JS bundle once and return what every target app's release is made from. A local key
|
|
505
550
|
// (--private-key <file>, or PULSAR_SIGNING_KEY holding the key itself) overrides Pulsar's signing.
|
|
506
|
-
function
|
|
507
|
-
const platform = getFlag(args, 'platform', 'ios');
|
|
551
|
+
function localSigningKey(args) {
|
|
508
552
|
const privateKeyPath = getFlag(args, 'private-key');
|
|
509
553
|
if (privateKeyPath && !fs.existsSync(privateKeyPath)) die(`no signing key at ${privateKeyPath} — drop --private-key to have Pulsar sign`);
|
|
510
|
-
|
|
554
|
+
return privateKeyPath ? fs.readFileSync(privateKeyPath, 'utf8') : (process.env.PULSAR_SIGNING_KEY || '').trim();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function checkPrivateBeforeBuild(apps, args) {
|
|
558
|
+
const localKey = localSigningKey(args);
|
|
559
|
+
for (const app of apps) {
|
|
560
|
+
const context = await privateContext(app, args, localKey);
|
|
561
|
+
if (context) reportReach(context, {mandatory: args.includes('--mandatory'), plainCopy: args.includes('--plain-copy-for-old-builds')});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function buildOnce(args) {
|
|
566
|
+
const platform = getFlag(args, 'platform', 'ios');
|
|
567
|
+
const localKey = localSigningKey(args);
|
|
511
568
|
const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
|
|
512
569
|
const sourcemapPath = args.includes('--no-sourcemap') ? null : `${contentsDir}.map`;
|
|
513
570
|
bundle(platform, contentsDir, sourcemapPath);
|
|
@@ -518,14 +575,15 @@ function buildOnce(args) {
|
|
|
518
575
|
|
|
519
576
|
async function cmdRelease(args) {
|
|
520
577
|
const [nameOrId, deployment = 'Staging'] = args;
|
|
521
|
-
if (!nameOrId) die('Usage: pulsar release <app> <Staging|Production> --platform ios|android --target <ver> [--mandatory] [--disabled] [--rollout N] [--schedule +6h|ISO] [--ramp 100@48] [--from v6,ota,binary] [--devices id1,id2,...] [--device-models model1;model2] [--users u1,u2] [--private-key key.pem] [--no-diff] [--no-sourcemap] [--max-growth <percent>] [--description "..."]');
|
|
578
|
+
if (!nameOrId) die('Usage: pulsar release <app> <Staging|Production> --platform ios|android --target <ver> [--mandatory] [--disabled] [--rollout N] [--schedule +6h|ISO] [--ramp 100@48] [--from v6,ota,binary] [--devices id1,id2,...] [--device-models model1;model2] [--users u1,u2] [--private-key key.pem] [--bundle-key key.txt] [--plain-copy-for-old-builds | --plain | --only-older-builds] [--no-diff] [--no-sourcemap] [--max-growth <percent>] [--description "..."]');
|
|
522
579
|
if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
|
|
523
580
|
const app = await resolveApp(nameOrId, getFlag(args, 'platform', 'ios'));
|
|
524
581
|
if (app.os !== getFlag(args, 'platform', 'ios')) {
|
|
525
582
|
console.log(`• note: resolved app "${app.name}" is ${app.os}; bundling ${getFlag(args, 'platform', 'ios')}`);
|
|
526
583
|
}
|
|
584
|
+
await checkPrivateBeforeBuild([app], args);
|
|
527
585
|
const {contentsDir, packageHash, localKey, sourcemapPath} = buildOnce(args);
|
|
528
|
-
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath});
|
|
586
|
+
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath, reachReported: true});
|
|
529
587
|
}
|
|
530
588
|
|
|
531
589
|
// Release the same built bundle to several apps at once (shared-codebase / white-label fleets).
|
|
@@ -550,11 +608,12 @@ async function cmdReleaseTrain(args) {
|
|
|
550
608
|
apps = apps.filter((app) => app.os === platform);
|
|
551
609
|
if (!apps.length) die(`No ${platform} apps matched.`);
|
|
552
610
|
|
|
611
|
+
await checkPrivateBeforeBuild(apps, args);
|
|
553
612
|
const {contentsDir, packageHash, localKey, sourcemapPath} = buildOnce(args);
|
|
554
613
|
console.log(`• release train → ${apps.length} app(s): ${apps.map((app) => app.name).join(', ')}`);
|
|
555
614
|
for (const app of apps) {
|
|
556
615
|
try {
|
|
557
|
-
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath});
|
|
616
|
+
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath, reachReported: true});
|
|
558
617
|
} catch (err) {
|
|
559
618
|
console.log(`✗ ${app.name}: ${err.message}`);
|
|
560
619
|
}
|
|
@@ -601,7 +660,8 @@ async function cmdHistory(args) {
|
|
|
601
660
|
const releases = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
|
|
602
661
|
if (!releases.length) return console.log('(no releases)');
|
|
603
662
|
for (const release of [...releases].reverse()) {
|
|
604
|
-
const
|
|
663
|
+
const privacy = release.encrypted ? ` [private${release.plainCopy ? ' + plain copy' : ''}]` : release.olderBuildsOnly ? ' [plain, older builds only]' : release.plainOnPurpose ? ' [plain]' : '';
|
|
664
|
+
const flags = `${release.isMandatory ? ' [mandatory]' : ''}${release.isDisabled ? ' [disabled]' : ''}${privacy}`;
|
|
605
665
|
const rollout = release.currentRollout ?? release.rollout;
|
|
606
666
|
const state = release.state && release.state !== 'live' ? ` [${release.state}]` : '';
|
|
607
667
|
const when = release.startsAt ? ` starts ${new Date(release.startsAt).toLocaleString()}` : '';
|
|
@@ -683,7 +743,7 @@ async function cmdKeys(args) {
|
|
|
683
743
|
console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
|
|
684
744
|
}
|
|
685
745
|
|
|
686
|
-
export {saveConfig, browserLogin, signCodePushJWT, SIGNATURE_PATH, api, die, getFlag, loadConfig, pushBuiltRelease, splitList};
|
|
746
|
+
export {saveConfig, browserLogin, signCodePushJWT, SIGNATURE_PATH, api, die, getFlag, loadConfig, pushBuiltRelease, splitList, resolveApp};
|
|
687
747
|
|
|
688
748
|
// Only dispatch when run as the CLI — importing the module (e.g. from tests) must not execute a command.
|
|
689
749
|
// Compare real paths: a global install runs through a bin symlink, so argv[1] is not this file's path.
|
|
@@ -709,6 +769,8 @@ if (invokedAsCli()) {
|
|
|
709
769
|
patch: cmdPatch,
|
|
710
770
|
keygen: cmdKeygen,
|
|
711
771
|
keys: cmdKeys,
|
|
772
|
+
'bundle-key': cmdBundleKey,
|
|
773
|
+
changes: cmdChanges,
|
|
712
774
|
migrate,
|
|
713
775
|
};
|
|
714
776
|
|
|
@@ -719,7 +781,7 @@ if (invokedAsCli()) {
|
|
|
719
781
|
}
|
|
720
782
|
|
|
721
783
|
(commands[command] || (() => {
|
|
722
|
-
console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen|migrate>');
|
|
784
|
+
console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen|bundle-key|changes|migrate>');
|
|
723
785
|
process.exit(command ? 1 : 0);
|
|
724
786
|
}))(rest);
|
|
725
787
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const LIST_LIMIT = 200;
|
|
2
|
+
|
|
3
|
+
export function compareManifests(before, after) {
|
|
4
|
+
const added = [];
|
|
5
|
+
const removed = [];
|
|
6
|
+
const modified = [];
|
|
7
|
+
let unchanged = 0;
|
|
8
|
+
for (const [filePath, entry] of Object.entries(after)) {
|
|
9
|
+
const previous = before[filePath];
|
|
10
|
+
if (!previous) added.push({path: filePath, size: entry.size});
|
|
11
|
+
else if (previous.hash !== entry.hash) modified.push({path: filePath, before: previous.size, after: entry.size});
|
|
12
|
+
else unchanged += 1;
|
|
13
|
+
}
|
|
14
|
+
for (const [filePath, entry] of Object.entries(before)) if (!after[filePath]) removed.push({path: filePath, size: entry.size});
|
|
15
|
+
const total = (manifest) => Object.values(manifest).reduce((sum, entry) => sum + entry.size, 0);
|
|
16
|
+
const bySize = (left, right) => (right.size ?? Math.abs(right.after - right.before)) - (left.size ?? Math.abs(left.after - left.before));
|
|
17
|
+
return {
|
|
18
|
+
added: added.sort(bySize).slice(0, LIST_LIMIT),
|
|
19
|
+
removed: removed.sort(bySize).slice(0, LIST_LIMIT),
|
|
20
|
+
modified: modified.sort(bySize).slice(0, LIST_LIMIT),
|
|
21
|
+
counts: {added: added.length, removed: removed.length, modified: modified.length, unchanged},
|
|
22
|
+
size: {before: total(before), after: total(after)},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const moduleName = (source) => {
|
|
27
|
+
const text = String(source || '');
|
|
28
|
+
const match = /(?:^|\/)(node_modules\/.*)$/.exec(text) || /(?:^|\/)(src\/.*)$/.exec(text);
|
|
29
|
+
return match ? match[1] : text.replace(/^.*\/(?=[^/]+\/[^/]+$)/, '');
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function compareModules(beforeSources, afterSources) {
|
|
33
|
+
const before = new Set((beforeSources || []).map(moduleName));
|
|
34
|
+
const after = new Set((afterSources || []).map(moduleName));
|
|
35
|
+
const added = [...after].filter((name) => !before.has(name));
|
|
36
|
+
const removed = [...before].filter((name) => !after.has(name));
|
|
37
|
+
return {added: added.slice(0, LIST_LIMIT), removed: removed.slice(0, LIST_LIMIT), counts: {added: added.length, removed: removed.length}};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function manifestParts(document) {
|
|
41
|
+
if (document?.files && typeof document.files === 'object' && !Array.isArray(document.files)) {
|
|
42
|
+
return {files: document.files, modules: Array.isArray(document.modules) ? document.modules : null};
|
|
43
|
+
}
|
|
44
|
+
return {files: document || null, modules: null};
|
|
45
|
+
}
|