@novacraft-engineering/pulsar-cli 1.2.4 → 1.4.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 +35 -0
- package/migrate.js +181 -0
- package/package.json +3 -2
- package/pulsar.js +111 -39
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ pulsar deployment ls <app> # list deployments + keys
|
|
|
34
34
|
pulsar deployment add <app> <name> # create a new deployment (e.g. QA)
|
|
35
35
|
pulsar history <app> <deployment> # release history
|
|
36
36
|
pulsar patch <app> <deployment> <label> [--rollout N] [--mandatory] [--disable] [--enable] [--devices id1,id2,...]
|
|
37
|
+
pulsar migrate --from <serverUrl> --access-key <key> # move apps from RevoPush / code-push-server (see below)
|
|
37
38
|
```
|
|
38
39
|
|
|
39
40
|
### Targeting a release's reach
|
|
@@ -52,6 +53,40 @@ then downloads it in the background and swaps it in on next launch.
|
|
|
52
53
|
|
|
53
54
|
Auth is read from `PULSAR_API_URL` / `PULSAR_TOKEN` env, or `~/.pulsar/config.json` (written by `pulsar login`). Nothing is committed.
|
|
54
55
|
|
|
56
|
+
## Migrating from RevoPush / code-push-server
|
|
57
|
+
|
|
58
|
+
`pulsar migrate` copies apps from any server that speaks the CodePush management API
|
|
59
|
+
(RevoPush, a self-hosted `microsoft/code-push-server`) into Pulsar:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pulsar migrate --from https://<your-codepush-server> --access-key <source access key> \
|
|
63
|
+
[--apps MyApp-iOS,MyApp-Android] [--org "My Org"] [--platform ios|android] \
|
|
64
|
+
[--releases latest|none] [--dry-run]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- Each source app becomes a Pulsar app in `--org` (optional when you belong to one org), or reuses
|
|
68
|
+
the Pulsar app with the same name and platform. Deployments keep their names; existing ones are reused.
|
|
69
|
+
- The platform comes from the source app's `os`, or its name (`*-iOS`, `*-Android`). Pass
|
|
70
|
+
`--platform` for apps named neither way.
|
|
71
|
+
- `--releases latest` (default) downloads each deployment's current release, re-hashes and signs it
|
|
72
|
+
the way `pulsar release` does, and publishes it with the same target version, description,
|
|
73
|
+
mandatory flag, rollout and disabled state. A release that can't be downloaded or unzipped is
|
|
74
|
+
reported and skipped. Re-running is safe: a bundle already on the Pulsar deployment is not re-uploaded.
|
|
75
|
+
- `--dry-run` reads both servers and prints the plan without writing anything to Pulsar.
|
|
76
|
+
|
|
77
|
+
The run ends with a table of source key → new Pulsar key per deployment. Devices keep checking the
|
|
78
|
+
old server until each app is rebuilt with its new key and Pulsar's URL:
|
|
79
|
+
|
|
80
|
+
```xml
|
|
81
|
+
<!-- iOS Info.plist -->
|
|
82
|
+
<key>CodePushDeploymentKey</key><string>NEW_PULSAR_KEY</string>
|
|
83
|
+
<key>CodePushServerURL</key><string>https://api.usepulsar.dev</string>
|
|
84
|
+
|
|
85
|
+
<!-- Android res/values/strings.xml -->
|
|
86
|
+
<string moduleConfig="true" name="CodePushDeploymentKey">NEW_PULSAR_KEY</string>
|
|
87
|
+
<string moduleConfig="true" name="CodePushServerUrl">https://api.usepulsar.dev</string>
|
|
88
|
+
```
|
|
89
|
+
|
|
55
90
|
## Use it from CI
|
|
56
91
|
|
|
57
92
|
Both env vars are all a GitHub Actions / GitLab CI job needs:
|
package/migrate.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {execFileSync} from 'node:child_process';
|
|
5
|
+
import {api, die, getFlag, loadConfig, pushBuiltRelease, splitList} from './pulsar.js';
|
|
6
|
+
import {computePackageHash} from './hash.js';
|
|
7
|
+
|
|
8
|
+
const USAGE = 'Usage: pulsar migrate --from <serverUrl> --access-key <key> [--apps A,B] [--org <name>] [--platform ios|android] [--releases latest|none] [--dry-run]';
|
|
9
|
+
|
|
10
|
+
async function sourceGet(source, pathname) {
|
|
11
|
+
const response = await fetch(source.url + pathname, {
|
|
12
|
+
headers: {authorization: `Bearer ${source.accessKey}`, accept: 'application/vnd.code-push.v2+json'},
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok) throw new Error(`GET ${pathname} → ${response.status} ${response.statusText}`);
|
|
15
|
+
return response.json();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function platformOf(sourceApp, fallback) {
|
|
19
|
+
const declared = String(sourceApp.os || '').toLowerCase();
|
|
20
|
+
if (declared === 'ios' || declared === 'android') return declared;
|
|
21
|
+
if (/android/i.test(sourceApp.name)) return 'android';
|
|
22
|
+
if (/(^|[^a-z])ios([^a-z]|$)/i.test(sourceApp.name)) return 'ios';
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function chooseOrg(orgName) {
|
|
27
|
+
const orgs = await api('/orgs');
|
|
28
|
+
if (orgName) {
|
|
29
|
+
const match = orgs.find((org) => org.name.toLowerCase() === orgName.toLowerCase());
|
|
30
|
+
if (!match) die(`Organization not found: ${orgName}. Known: ${orgs.map((org) => org.name).join(', ') || '(none)'}`);
|
|
31
|
+
return match;
|
|
32
|
+
}
|
|
33
|
+
if (orgs.length === 1) return orgs[0];
|
|
34
|
+
die(`Pass --org <name> to choose where the apps go. Known: ${orgs.map((org) => org.name).join(', ') || '(none)'}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function migrateRelease({source, pulsarApp, deploymentName, sourcePackage, dryRun}) {
|
|
38
|
+
if (!sourcePackage) return 'no release on source';
|
|
39
|
+
if (!sourcePackage.appVersion) return `not migrated: ${sourcePackage.label} has no target app version`;
|
|
40
|
+
if (dryRun) return `would re-release ${sourcePackage.label} (target ${sourcePackage.appVersion})`;
|
|
41
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-migrate-'));
|
|
42
|
+
const zipPath = path.join(workDir, 'source.zip');
|
|
43
|
+
const contentsDir = path.join(workDir, 'contents');
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(new URL(sourcePackage.blobUrl, source.url)).catch((err) => {
|
|
46
|
+
throw new Error(`download failed (${err.message})`);
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) throw new Error(`download failed (${response.status})`);
|
|
49
|
+
fs.writeFileSync(zipPath, Buffer.from(await response.arrayBuffer()));
|
|
50
|
+
try {
|
|
51
|
+
execFileSync('unzip', ['-qq', '-o', zipPath, '-d', contentsDir], {stdio: 'pipe'});
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error('downloaded bundle is not a readable zip');
|
|
54
|
+
}
|
|
55
|
+
const packageHash = computePackageHash(contentsDir);
|
|
56
|
+
const existing = await api(`/apps/${pulsarApp.id}/deployments/${deploymentName}/releases`);
|
|
57
|
+
const already = existing.find((entry) => entry.packageHash === packageHash);
|
|
58
|
+
if (already) return `${sourcePackage.label} already on Pulsar as ${already.label}`;
|
|
59
|
+
|
|
60
|
+
const releaseArgs = [
|
|
61
|
+
'--target', sourcePackage.appVersion,
|
|
62
|
+
'--description', sourcePackage.description || '',
|
|
63
|
+
'--rollout', String(sourcePackage.rollout ?? 100),
|
|
64
|
+
...(sourcePackage.isMandatory ? ['--mandatory'] : []),
|
|
65
|
+
...(sourcePackage.isDisabled ? ['--disabled'] : []),
|
|
66
|
+
];
|
|
67
|
+
const localKey = (process.env.PULSAR_SIGNING_KEY || '').trim();
|
|
68
|
+
const release = await pushBuiltRelease(pulsarApp, deploymentName, releaseArgs, contentsDir, packageHash, localKey);
|
|
69
|
+
const notes = [
|
|
70
|
+
sourcePackage.packageHash && sourcePackage.packageHash !== packageHash ? 're-hashed: source hash differed' : null,
|
|
71
|
+
release.signed === false ? 'UNSIGNED, blocked until an org owner approves it' : null,
|
|
72
|
+
release.isDisabled ? 'disabled' : null,
|
|
73
|
+
].filter(Boolean);
|
|
74
|
+
return `${sourcePackage.label} → ${release.label}${notes.length ? ` (${notes.join('; ')})` : ''}`;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return `not migrated: ${err.message}`;
|
|
77
|
+
} finally {
|
|
78
|
+
fs.rmSync(workDir, {recursive: true, force: true});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function migrateApp({source, sourceApp, org, pulsarApps, args, dryRun, rows}) {
|
|
83
|
+
const name = sourceApp.name.split(':').pop();
|
|
84
|
+
const platform = platformOf(sourceApp, getFlag(args, 'platform'));
|
|
85
|
+
const skipRow = (reason) => rows.push({app: name, deployment: '-', sourceKey: '-', pulsarKey: '-', release: reason});
|
|
86
|
+
if (!platform) return skipRow('skipped: platform unknown, pass --platform ios|android');
|
|
87
|
+
|
|
88
|
+
let deployments;
|
|
89
|
+
try {
|
|
90
|
+
({deployments} = await sourceGet(source, `/apps/${encodeURIComponent(sourceApp.name)}/deployments`));
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return skipRow(`skipped: ${err.message}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let pulsarApp = pulsarApps.find((app) => app.orgId === org.id && app.name === name && app.os === platform);
|
|
96
|
+
if (pulsarApp) console.log(`• ${name} [${platform}]: reusing the existing Pulsar app`);
|
|
97
|
+
else if (dryRun) console.log(`• ${name} [${platform}]: would create in ${org.name}`);
|
|
98
|
+
else {
|
|
99
|
+
pulsarApp = await api('/apps', {method: 'POST', body: {name, os: platform, orgId: org.id}});
|
|
100
|
+
console.log(`• ${name} [${platform}]: created in ${org.name}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const {name: deploymentName} of deployments) {
|
|
104
|
+
const row = {app: `${name} [${platform}]`, deployment: deploymentName, sourceKey: '-', pulsarKey: '-', release: ''};
|
|
105
|
+
rows.push(row);
|
|
106
|
+
let sourceDeployment;
|
|
107
|
+
try {
|
|
108
|
+
({deployment: sourceDeployment} = await sourceGet(
|
|
109
|
+
source,
|
|
110
|
+
`/apps/${encodeURIComponent(sourceApp.name)}/deployments/${encodeURIComponent(deploymentName)}`,
|
|
111
|
+
));
|
|
112
|
+
} catch (err) {
|
|
113
|
+
row.release = `not migrated: ${err.message}`;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
row.sourceKey = sourceDeployment.key || '-';
|
|
117
|
+
|
|
118
|
+
row.pulsarKey = pulsarApp?.deployments?.[deploymentName]?.key;
|
|
119
|
+
if (!row.pulsarKey && dryRun) row.pulsarKey = '(new)';
|
|
120
|
+
else if (!row.pulsarKey) {
|
|
121
|
+
({key: row.pulsarKey} = await api(`/apps/${pulsarApp.id}/deployments`, {method: 'POST', body: {name: deploymentName}}));
|
|
122
|
+
console.log(` added deployment ${deploymentName}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
row.release = getFlag(args, 'releases', 'latest') === 'none'
|
|
126
|
+
? 'skipped (--releases none)'
|
|
127
|
+
: await migrateRelease({source, pulsarApp, deploymentName, sourcePackage: sourceDeployment.package, dryRun});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function printSummary(rows) {
|
|
132
|
+
const columns = [['app', 'App'], ['deployment', 'Deployment'], ['sourceKey', 'Source key'], ['pulsarKey', 'Pulsar key'], ['release', 'Release']];
|
|
133
|
+
const widths = columns.map(([field, title]) => Math.max(title.length, ...rows.map((row) => String(row[field]).length)));
|
|
134
|
+
const line = (cells) => cells.map((cell, index) => String(cell).padEnd(widths[index])).join(' ').trimEnd();
|
|
135
|
+
console.log(`\n${line(columns.map(([, title]) => title))}`);
|
|
136
|
+
console.log(line(widths.map((width) => '-'.repeat(width))));
|
|
137
|
+
for (const row of rows) console.log(line(columns.map(([field]) => row[field])));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function printRebuildReminder(serverUrl) {
|
|
141
|
+
console.log(`
|
|
142
|
+
Devices keep checking the old server until each app is rebuilt with its new Pulsar key and server URL.
|
|
143
|
+
|
|
144
|
+
iOS — Info.plist:
|
|
145
|
+
<key>CodePushDeploymentKey</key>
|
|
146
|
+
<string>PULSAR_KEY_FROM_TABLE</string>
|
|
147
|
+
<key>CodePushServerURL</key>
|
|
148
|
+
<string>${serverUrl}</string>
|
|
149
|
+
|
|
150
|
+
Android — android/app/src/main/res/values/strings.xml:
|
|
151
|
+
<string moduleConfig="true" name="CodePushDeploymentKey">PULSAR_KEY_FROM_TABLE</string>
|
|
152
|
+
<string moduleConfig="true" name="CodePushServerUrl">${serverUrl}</string>`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function migrate(args) {
|
|
156
|
+
const from = getFlag(args, 'from');
|
|
157
|
+
const accessKey = getFlag(args, 'access-key');
|
|
158
|
+
if (!from || !accessKey) die(USAGE);
|
|
159
|
+
const releasesMode = getFlag(args, 'releases', 'latest');
|
|
160
|
+
if (!['latest', 'none'].includes(releasesMode)) die(`--releases must be latest or none\n${USAGE}`);
|
|
161
|
+
const dryRun = args.includes('--dry-run');
|
|
162
|
+
const source = {url: from.replace(/\/$/, ''), accessKey};
|
|
163
|
+
|
|
164
|
+
const {apps: sourceApps = []} = await sourceGet(source, '/apps').catch((err) => die(`Could not list apps on ${from}: ${err.message}`));
|
|
165
|
+
const wanted = splitList(getFlag(args, 'apps'), ',');
|
|
166
|
+
const selected = wanted.length ? sourceApps.filter((app) => wanted.includes(app.name) || wanted.includes(app.name.split(':').pop())) : sourceApps;
|
|
167
|
+
const unmatched = wanted.filter((wantedName) => !selected.some((app) => app.name === wantedName || app.name.split(':').pop() === wantedName));
|
|
168
|
+
if (unmatched.length) console.log(`⚠ not on the source server: ${unmatched.join(', ')}`);
|
|
169
|
+
if (!selected.length) die(`No apps to migrate. Source has: ${sourceApps.map((app) => app.name).join(', ') || '(none)'}`);
|
|
170
|
+
|
|
171
|
+
const org = await chooseOrg(getFlag(args, 'org'));
|
|
172
|
+
const pulsarApps = await api('/apps');
|
|
173
|
+
console.log(`${dryRun ? 'DRY RUN — nothing will be written to Pulsar.\n' : ''}Migrating ${selected.length} app(s) from ${source.url} into ${org.name}`);
|
|
174
|
+
|
|
175
|
+
const rows = [];
|
|
176
|
+
for (const sourceApp of selected) {
|
|
177
|
+
await migrateApp({source, sourceApp, org, pulsarApps, args, dryRun, rows});
|
|
178
|
+
}
|
|
179
|
+
printSummary(rows);
|
|
180
|
+
printRebuildReminder(loadConfig().apiUrl);
|
|
181
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/pulsar-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"pulsar.js",
|
|
11
11
|
"hash.js",
|
|
12
|
+
"migrate.js",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
"node": ">=18"
|
|
27
28
|
},
|
|
28
29
|
"scripts": {
|
|
29
|
-
"test": "node test/hash.test.js && node test/security.test.js && node test/signing.test.js && node test/entry.test.js"
|
|
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"
|
|
30
31
|
},
|
|
31
32
|
"keywords": [
|
|
32
33
|
"cli",
|
package/pulsar.js
CHANGED
|
@@ -8,6 +8,7 @@ import {execFileSync} from 'node:child_process';
|
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
9
|
import {fileURLToPath} from 'node:url';
|
|
10
10
|
import {computePackageHash, fileHashes} from './hash.js';
|
|
11
|
+
import {migrate} from './migrate.js';
|
|
11
12
|
|
|
12
13
|
// Code signing: sign the package hash into an RS256 JWT the client verifies against its
|
|
13
14
|
// CodePushPublicKey (Info.plist). Both native SDKs read it from <update>/CodePush/.codepushrelease
|
|
@@ -52,7 +53,7 @@ function saveConfig(config) {
|
|
|
52
53
|
try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* best-effort on pre-existing files */ }
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous, onUploadProgress} = {}) {
|
|
56
|
+
async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous, onUploadProgress, optional} = {}) {
|
|
56
57
|
const {apiUrl, token} = loadConfig();
|
|
57
58
|
if (!apiUrl) die('No API url. Run: pulsar login <apiUrl>');
|
|
58
59
|
// Every call except the login handshake must carry a token, so an expired or
|
|
@@ -95,6 +96,7 @@ async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnon
|
|
|
95
96
|
} catch {
|
|
96
97
|
detail = text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
97
98
|
}
|
|
99
|
+
if (optional) throw new Error(`${response.status} ${detail}`);
|
|
98
100
|
die(`${response.status} ${response.statusText}: ${detail}`);
|
|
99
101
|
}
|
|
100
102
|
try {
|
|
@@ -201,7 +203,7 @@ async function resolveApp(nameOrId, platform) {
|
|
|
201
203
|
die(`Multiple apps named "${nameOrId}" (${named.map((app) => app.os).join(', ')}). Pass --platform ios|android, or use the app id.`);
|
|
202
204
|
}
|
|
203
205
|
|
|
204
|
-
function bundle(platform, contentsDir) {
|
|
206
|
+
function bundle(platform, contentsDir, sourcemapPath) {
|
|
205
207
|
const bundleName = platform === 'ios' ? 'main.jsbundle' : 'index.android.bundle';
|
|
206
208
|
fs.mkdirSync(contentsDir, {recursive: true});
|
|
207
209
|
console.log(`• bundling ${platform}…`);
|
|
@@ -215,11 +217,22 @@ function bundle(platform, contentsDir) {
|
|
|
215
217
|
'--entry-file', 'index.js',
|
|
216
218
|
'--bundle-output', path.join(contentsDir, bundleName),
|
|
217
219
|
'--assets-dest', contentsDir,
|
|
220
|
+
...(sourcemapPath ? ['--sourcemap-output', sourcemapPath] : []),
|
|
218
221
|
],
|
|
219
222
|
{stdio: 'inherit'},
|
|
220
223
|
);
|
|
221
224
|
}
|
|
222
225
|
|
|
226
|
+
function fileManifest(contentsDir) {
|
|
227
|
+
const hashes = fileHashes(contentsDir);
|
|
228
|
+
return Object.fromEntries(Object.entries(hashes).map(([relativePath, hash]) => [relativePath, {hash, size: fs.statSync(path.join(contentsDir, relativePath)).size}]));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function sizeGrowth(previousSize, nextSize) {
|
|
232
|
+
if (!(previousSize > 0) || !(nextSize > 0)) return null;
|
|
233
|
+
return Math.round(((nextSize - previousSize) / previousSize) * 100);
|
|
234
|
+
}
|
|
235
|
+
|
|
223
236
|
function zipContents(contentsDir) {
|
|
224
237
|
const zipPath = path.join(os.tmpdir(), `pulsar-${Date.now()}.zip`);
|
|
225
238
|
execFileSync('zip', ['-qq', '-r', '-X', zipPath, '.'], {cwd: contentsDir});
|
|
@@ -350,15 +363,56 @@ async function cmdApps() {
|
|
|
350
363
|
}
|
|
351
364
|
}
|
|
352
365
|
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
async function
|
|
356
|
-
|
|
357
|
-
|
|
366
|
+
// Sign for this app — with a local key when one was given, otherwise by asking Pulsar, which signs for
|
|
367
|
+
// anyone allowed to publish here. Each app can hold a different key, so a release train re-signs.
|
|
368
|
+
async function signForApp(app, deployment, contentsDir, packageHash, localKey) {
|
|
369
|
+
const signaturePath = path.join(contentsDir, SIGNATURE_PATH);
|
|
370
|
+
fs.rmSync(signaturePath, {force: true});
|
|
371
|
+
let signature = null;
|
|
372
|
+
if (localKey) {
|
|
358
373
|
try {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
374
|
+
signature = signCodePushJWT(localKey, packageHash);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
die(`could not sign with the provided key (${error.message}) — nothing was uploaded`);
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
({signature} = await api(`/apps/${app.id}/deployments/${deployment}/sign`, {method: 'POST', body: {packageHash}}));
|
|
380
|
+
}
|
|
381
|
+
if (!signature) {
|
|
382
|
+
console.log(`• ${app.name} has no signing key — uploading unsigned`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
fs.mkdirSync(path.dirname(signaturePath), {recursive: true});
|
|
386
|
+
fs.writeFileSync(signaturePath, signature);
|
|
387
|
+
console.log(`• signed${localKey ? ' with your local key' : ' by Pulsar'}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Sign and push an already-built bundle to one app's deployment, then generate and upload a
|
|
391
|
+
// differential patch against whatever that deployment was serving.
|
|
392
|
+
async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath} = {}) {
|
|
393
|
+
await signForApp(app, deployment, contentsDir, packageHash, localKey);
|
|
394
|
+
const zip = fs.readFileSync(zipContents(contentsDir));
|
|
395
|
+
let prevLatest = null;
|
|
396
|
+
try {
|
|
397
|
+
const existing = await api(`/apps/${app.id}/deployments/${deployment}/releases`, {optional: true});
|
|
398
|
+
prevLatest = [...existing].reverse().find((entry) => !entry.isDisabled) || existing[existing.length - 1] || null;
|
|
399
|
+
} catch {}
|
|
400
|
+
try {
|
|
401
|
+
const target = getFlag(args, 'target');
|
|
402
|
+
const {versions, reach, recentDays} = await api(`/apps/${app.id}/binary-versions?target=${encodeURIComponent(target)}`, {optional: true});
|
|
403
|
+
if (versions?.length && reach && !reach.everLaunched) {
|
|
404
|
+
console.log(`⚠ no device has reported a store build matching ${target}; this release reaches nobody yet (builds seen: ${versions.slice(0, 5).map((entry) => entry.version).join(', ')})`);
|
|
405
|
+
} else if (versions?.length && reach && !reach.recentLaunches) {
|
|
406
|
+
console.log(`⚠ no store build matching ${target} was opened in the last ${recentDays} days (matches: ${reach.matchingVersions.join(', ')})`);
|
|
407
|
+
}
|
|
408
|
+
} catch {}
|
|
409
|
+
const growth = sizeGrowth(prevLatest?.size, zip.length);
|
|
410
|
+
if (growth !== null && growth > 20) {
|
|
411
|
+
console.log(`⚠ bundle is ${growth}% larger than ${prevLatest.label} (${(prevLatest.size / 1e6).toFixed(2)}MB → ${(zip.length / 1e6).toFixed(2)}MB)`);
|
|
412
|
+
}
|
|
413
|
+
const maxGrowth = getFlag(args, 'max-growth');
|
|
414
|
+
if (maxGrowth !== undefined && growth !== null && growth > Number(maxGrowth)) {
|
|
415
|
+
die(`bundle grew ${growth}%, more than --max-growth ${maxGrowth}%. Nothing was released.`);
|
|
362
416
|
}
|
|
363
417
|
|
|
364
418
|
const startsAt = parseSchedule(getFlag(args, 'schedule'));
|
|
@@ -393,10 +447,38 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
393
447
|
].filter(Boolean);
|
|
394
448
|
const reach = scopes.length ? scopes.join(' + ') : `${release.rollout}% of devices`;
|
|
395
449
|
console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}`);
|
|
450
|
+
if (release.approval && !release.approval.approvedAt) {
|
|
451
|
+
console.log(`⏸ waiting for approval: ${deployment} needs a second admin or owner to approve ${release.label} in the dashboard before devices get it`);
|
|
452
|
+
}
|
|
453
|
+
if (release.signed === false) {
|
|
454
|
+
console.log('⚠ UNSIGNED — this build is blocked and will not reach devices. An org owner can enable it from the dashboard.');
|
|
455
|
+
}
|
|
396
456
|
if (release.startsAt) console.log(` scheduled — goes live ${new Date(release.startsAt).toLocaleString()}`);
|
|
397
457
|
if (release.rolloutRamp) console.log(` gradual rollout — ${release.rollout}% to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h`);
|
|
398
458
|
|
|
399
|
-
|
|
459
|
+
try {
|
|
460
|
+
await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/manifest`, {method: 'POST', body: {files: fileManifest(contentsDir)}, optional: true});
|
|
461
|
+
} catch (err) {
|
|
462
|
+
console.log(`• file list not recorded: ${err.message}`);
|
|
463
|
+
}
|
|
464
|
+
if (sourcemapPath && fs.existsSync(sourcemapPath)) {
|
|
465
|
+
try {
|
|
466
|
+
const map = fs.readFileSync(sourcemapPath);
|
|
467
|
+
await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/sourcemap`, {
|
|
468
|
+
method: 'POST',
|
|
469
|
+
raw: true,
|
|
470
|
+
optional: true,
|
|
471
|
+
body: map,
|
|
472
|
+
onUploadProgress: uploadProgress(`uploading ${(map.length / 1e6).toFixed(1)}MB source map`),
|
|
473
|
+
headers: {'content-type': 'application/json'},
|
|
474
|
+
});
|
|
475
|
+
console.log('• source map uploaded: crash stacks for this release will show your source files');
|
|
476
|
+
} catch (err) {
|
|
477
|
+
console.log(`• source map not uploaded: ${err.message}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (!args.includes('--no-diff') && prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
|
|
400
482
|
try {
|
|
401
483
|
const {apiUrl} = loadConfig();
|
|
402
484
|
const baseZip = await fetchBlob(apiUrl, app.id, `${prevLatest.packageHash}.zip`);
|
|
@@ -405,6 +487,7 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
405
487
|
await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/diff`, {
|
|
406
488
|
method: 'POST',
|
|
407
489
|
raw: true,
|
|
490
|
+
optional: true,
|
|
408
491
|
body: diffZip,
|
|
409
492
|
onUploadProgress: uploadProgress(`uploading ${(diffZip.length / 1e6).toFixed(1)}MB diff`),
|
|
410
493
|
headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
|
|
@@ -418,43 +501,31 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
418
501
|
return release;
|
|
419
502
|
}
|
|
420
503
|
|
|
421
|
-
// Build the JS bundle once
|
|
504
|
+
// Build the JS bundle once and return what every target app's release is made from. A local key
|
|
505
|
+
// (--private-key <file>, or PULSAR_SIGNING_KEY holding the key itself) overrides Pulsar's signing.
|
|
422
506
|
function buildOnce(args) {
|
|
423
507
|
const platform = getFlag(args, 'platform', 'ios');
|
|
508
|
+
const privateKeyPath = getFlag(args, 'private-key');
|
|
509
|
+
if (privateKeyPath && !fs.existsSync(privateKeyPath)) die(`no signing key at ${privateKeyPath} — drop --private-key to have Pulsar sign`);
|
|
510
|
+
const localKey = privateKeyPath ? fs.readFileSync(privateKeyPath, 'utf8') : (process.env.PULSAR_SIGNING_KEY || '').trim();
|
|
424
511
|
const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
|
|
425
|
-
|
|
512
|
+
const sourcemapPath = args.includes('--no-sourcemap') ? null : `${contentsDir}.map`;
|
|
513
|
+
bundle(platform, contentsDir, sourcemapPath);
|
|
426
514
|
const packageHash = computePackageHash(contentsDir);
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
// "don't sign", exactly as before.
|
|
430
|
-
const privateKeyPath = getFlag(args, 'private-key');
|
|
431
|
-
const signingKey = privateKeyPath ? fs.readFileSync(privateKeyPath, 'utf8') : (process.env.PULSAR_SIGNING_KEY || '').trim();
|
|
432
|
-
if (signingKey) {
|
|
433
|
-
let signature;
|
|
434
|
-
try {
|
|
435
|
-
signature = signCodePushJWT(signingKey, packageHash);
|
|
436
|
-
} catch (error) {
|
|
437
|
-
die(`could not sign with the provided key (${error.message}) — nothing was uploaded`);
|
|
438
|
-
}
|
|
439
|
-
fs.mkdirSync(path.join(contentsDir, 'CodePush'), {recursive: true});
|
|
440
|
-
fs.writeFileSync(path.join(contentsDir, SIGNATURE_PATH), signature);
|
|
441
|
-
console.log('• signed release — devices with CodePushPublicKey set will verify it');
|
|
442
|
-
}
|
|
443
|
-
const zip = fs.readFileSync(zipContents(contentsDir));
|
|
444
|
-
console.log(`• hash ${packageHash.slice(0, 12)}… size ${(zip.length / 1e6).toFixed(2)} MB`);
|
|
445
|
-
return {contentsDir, packageHash, zip};
|
|
515
|
+
console.log(`• hash ${packageHash.slice(0, 12)}…`);
|
|
516
|
+
return {contentsDir, packageHash, localKey, sourcemapPath};
|
|
446
517
|
}
|
|
447
518
|
|
|
448
519
|
async function cmdRelease(args) {
|
|
449
520
|
const [nameOrId, deployment = 'Staging'] = args;
|
|
450
|
-
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] [--description "..."]');
|
|
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 "..."]');
|
|
451
522
|
if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
|
|
452
523
|
const app = await resolveApp(nameOrId, getFlag(args, 'platform', 'ios'));
|
|
453
524
|
if (app.os !== getFlag(args, 'platform', 'ios')) {
|
|
454
525
|
console.log(`• note: resolved app "${app.name}" is ${app.os}; bundling ${getFlag(args, 'platform', 'ios')}`);
|
|
455
526
|
}
|
|
456
|
-
const {contentsDir, packageHash,
|
|
457
|
-
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
527
|
+
const {contentsDir, packageHash, localKey, sourcemapPath} = buildOnce(args);
|
|
528
|
+
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath});
|
|
458
529
|
}
|
|
459
530
|
|
|
460
531
|
// Release the same built bundle to several apps at once (shared-codebase / white-label fleets).
|
|
@@ -479,11 +550,11 @@ async function cmdReleaseTrain(args) {
|
|
|
479
550
|
apps = apps.filter((app) => app.os === platform);
|
|
480
551
|
if (!apps.length) die(`No ${platform} apps matched.`);
|
|
481
552
|
|
|
482
|
-
const {contentsDir, packageHash,
|
|
553
|
+
const {contentsDir, packageHash, localKey, sourcemapPath} = buildOnce(args);
|
|
483
554
|
console.log(`• release train → ${apps.length} app(s): ${apps.map((app) => app.name).join(', ')}`);
|
|
484
555
|
for (const app of apps) {
|
|
485
556
|
try {
|
|
486
|
-
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
557
|
+
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath});
|
|
487
558
|
} catch (err) {
|
|
488
559
|
console.log(`✗ ${app.name}: ${err.message}`);
|
|
489
560
|
}
|
|
@@ -612,7 +683,7 @@ async function cmdKeys(args) {
|
|
|
612
683
|
console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
|
|
613
684
|
}
|
|
614
685
|
|
|
615
|
-
export {saveConfig, browserLogin, signCodePushJWT, SIGNATURE_PATH};
|
|
686
|
+
export {saveConfig, browserLogin, signCodePushJWT, SIGNATURE_PATH, api, die, getFlag, loadConfig, pushBuiltRelease, splitList};
|
|
616
687
|
|
|
617
688
|
// Only dispatch when run as the CLI — importing the module (e.g. from tests) must not execute a command.
|
|
618
689
|
// Compare real paths: a global install runs through a bin symlink, so argv[1] is not this file's path.
|
|
@@ -638,6 +709,7 @@ if (invokedAsCli()) {
|
|
|
638
709
|
patch: cmdPatch,
|
|
639
710
|
keygen: cmdKeygen,
|
|
640
711
|
keys: cmdKeys,
|
|
712
|
+
migrate,
|
|
641
713
|
};
|
|
642
714
|
|
|
643
715
|
const ANONYMOUS_COMMANDS = new Set(['login', 'keygen']);
|
|
@@ -647,7 +719,7 @@ if (invokedAsCli()) {
|
|
|
647
719
|
}
|
|
648
720
|
|
|
649
721
|
(commands[command] || (() => {
|
|
650
|
-
console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen>');
|
|
722
|
+
console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen|migrate>');
|
|
651
723
|
process.exit(command ? 1 : 0);
|
|
652
724
|
}))(rest);
|
|
653
725
|
}
|