@novacraft-engineering/pulsar-cli 1.3.0 → 1.4.1

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.
Files changed (4) hide show
  1. package/README.md +35 -0
  2. package/migrate.js +181 -0
  3. package/package.json +3 -2
  4. package/pulsar.js +80 -21
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.0",
3
+ "version": "1.4.1",
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
@@ -41,18 +42,20 @@ function loadConfig() {
41
42
  return {
42
43
  apiUrl: process.env.PULSAR_API_URL || fromFile.apiUrl,
43
44
  token: process.env.PULSAR_TOKEN || fromFile.token,
45
+ deviceId: fromFile.deviceId,
44
46
  };
45
47
  }
46
48
 
47
49
  function saveConfig(config) {
48
50
  // The config holds a bearer token — keep the dir and file owner-only so other local users on a
49
51
  // shared host can't read the session.
52
+ const deviceId = config.deviceId || loadConfig().deviceId;
50
53
  fs.mkdirSync(CONFIG_DIR, {recursive: true, mode: 0o700});
51
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {mode: 0o600});
54
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify({...config, deviceId}, null, 2), {mode: 0o600});
52
55
  try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* best-effort on pre-existing files */ }
53
56
  }
54
57
 
55
- async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous, onUploadProgress} = {}) {
58
+ async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous, onUploadProgress, optional} = {}) {
56
59
  const {apiUrl, token} = loadConfig();
57
60
  if (!apiUrl) die('No API url. Run: pulsar login <apiUrl>');
58
61
  // Every call except the login handshake must carry a token, so an expired or
@@ -95,6 +98,7 @@ async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnon
95
98
  } catch {
96
99
  detail = text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300);
97
100
  }
101
+ if (optional) throw new Error(`${response.status} ${detail}`);
98
102
  die(`${response.status} ${response.statusText}: ${detail}`);
99
103
  }
100
104
  try {
@@ -201,7 +205,7 @@ async function resolveApp(nameOrId, platform) {
201
205
  die(`Multiple apps named "${nameOrId}" (${named.map((app) => app.os).join(', ')}). Pass --platform ios|android, or use the app id.`);
202
206
  }
203
207
 
204
- function bundle(platform, contentsDir) {
208
+ function bundle(platform, contentsDir, sourcemapPath) {
205
209
  const bundleName = platform === 'ios' ? 'main.jsbundle' : 'index.android.bundle';
206
210
  fs.mkdirSync(contentsDir, {recursive: true});
207
211
  console.log(`• bundling ${platform}…`);
@@ -215,11 +219,22 @@ function bundle(platform, contentsDir) {
215
219
  '--entry-file', 'index.js',
216
220
  '--bundle-output', path.join(contentsDir, bundleName),
217
221
  '--assets-dest', contentsDir,
222
+ ...(sourcemapPath ? ['--sourcemap-output', sourcemapPath] : []),
218
223
  ],
219
224
  {stdio: 'inherit'},
220
225
  );
221
226
  }
222
227
 
228
+ function fileManifest(contentsDir) {
229
+ const hashes = fileHashes(contentsDir);
230
+ return Object.fromEntries(Object.entries(hashes).map(([relativePath, hash]) => [relativePath, {hash, size: fs.statSync(path.join(contentsDir, relativePath)).size}]));
231
+ }
232
+
233
+ function sizeGrowth(previousSize, nextSize) {
234
+ if (!(previousSize > 0) || !(nextSize > 0)) return null;
235
+ return Math.round(((nextSize - previousSize) / previousSize) * 100);
236
+ }
237
+
223
238
  function zipContents(contentsDir) {
224
239
  const zipPath = path.join(os.tmpdir(), `pulsar-${Date.now()}.zip`);
225
240
  execFileSync('zip', ['-qq', '-r', '-X', zipPath, '.'], {cwd: contentsDir});
@@ -334,8 +349,9 @@ async function cmdLogin(args) {
334
349
 
335
350
  const email = await prompt('Email: ');
336
351
  const password = await prompt('Password: ', true);
337
- const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, allowAnonymous: true});
338
- saveConfig({apiUrl, token});
352
+ const deviceId = loadConfig().deviceId || crypto.randomBytes(16).toString('hex');
353
+ const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, headers: {'x-device-id': deviceId}, allowAnonymous: true});
354
+ saveConfig({apiUrl, token, deviceId});
339
355
  console.log(`✓ logged in as ${user.email} (${user.platformRole})`);
340
356
  }
341
357
 
@@ -376,15 +392,30 @@ async function signForApp(app, deployment, contentsDir, packageHash, localKey) {
376
392
 
377
393
  // Sign and push an already-built bundle to one app's deployment, then generate and upload a
378
394
  // differential patch against whatever that deployment was serving.
379
- async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey) {
395
+ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath} = {}) {
380
396
  await signForApp(app, deployment, contentsDir, packageHash, localKey);
381
397
  const zip = fs.readFileSync(zipContents(contentsDir));
382
398
  let prevLatest = null;
383
- if (!args.includes('--no-diff')) {
384
- try {
385
- const existing = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
386
- prevLatest = [...existing].reverse().find((entry) => !entry.isDisabled) || existing[existing.length - 1] || null;
387
- } catch {}
399
+ try {
400
+ const existing = await api(`/apps/${app.id}/deployments/${deployment}/releases`, {optional: true});
401
+ prevLatest = [...existing].reverse().find((entry) => !entry.isDisabled) || existing[existing.length - 1] || null;
402
+ } catch {}
403
+ try {
404
+ const target = getFlag(args, 'target');
405
+ const {versions, reach, recentDays} = await api(`/apps/${app.id}/binary-versions?target=${encodeURIComponent(target)}`, {optional: true});
406
+ if (versions?.length && reach && !reach.everLaunched) {
407
+ 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(', ')})`);
408
+ } else if (versions?.length && reach && !reach.recentLaunches) {
409
+ console.log(`⚠ no store build matching ${target} was opened in the last ${recentDays} days (matches: ${reach.matchingVersions.join(', ')})`);
410
+ }
411
+ } catch {}
412
+ const growth = sizeGrowth(prevLatest?.size, zip.length);
413
+ if (growth !== null && growth > 20) {
414
+ console.log(`⚠ bundle is ${growth}% larger than ${prevLatest.label} (${(prevLatest.size / 1e6).toFixed(2)}MB → ${(zip.length / 1e6).toFixed(2)}MB)`);
415
+ }
416
+ const maxGrowth = getFlag(args, 'max-growth');
417
+ if (maxGrowth !== undefined && growth !== null && growth > Number(maxGrowth)) {
418
+ die(`bundle grew ${growth}%, more than --max-growth ${maxGrowth}%. Nothing was released.`);
388
419
  }
389
420
 
390
421
  const startsAt = parseSchedule(getFlag(args, 'schedule'));
@@ -419,13 +450,38 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
419
450
  ].filter(Boolean);
420
451
  const reach = scopes.length ? scopes.join(' + ') : `${release.rollout}% of devices`;
421
452
  console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}`);
453
+ if (release.approval && !release.approval.approvedAt) {
454
+ console.log(`⏸ waiting for approval: ${deployment} needs a second admin or owner to approve ${release.label} in the dashboard before devices get it`);
455
+ }
422
456
  if (release.signed === false) {
423
457
  console.log('⚠ UNSIGNED — this build is blocked and will not reach devices. An org owner can enable it from the dashboard.');
424
458
  }
425
459
  if (release.startsAt) console.log(` scheduled — goes live ${new Date(release.startsAt).toLocaleString()}`);
426
460
  if (release.rolloutRamp) console.log(` gradual rollout — ${release.rollout}% to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h`);
427
461
 
428
- if (prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
462
+ try {
463
+ await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/manifest`, {method: 'POST', body: {files: fileManifest(contentsDir)}, optional: true});
464
+ } catch (err) {
465
+ console.log(`• file list not recorded: ${err.message}`);
466
+ }
467
+ if (sourcemapPath && fs.existsSync(sourcemapPath)) {
468
+ try {
469
+ const map = fs.readFileSync(sourcemapPath);
470
+ await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/sourcemap`, {
471
+ method: 'POST',
472
+ raw: true,
473
+ optional: true,
474
+ body: map,
475
+ onUploadProgress: uploadProgress(`uploading ${(map.length / 1e6).toFixed(1)}MB source map`),
476
+ headers: {'content-type': 'application/json'},
477
+ });
478
+ console.log('• source map uploaded: crash stacks for this release will show your source files');
479
+ } catch (err) {
480
+ console.log(`• source map not uploaded: ${err.message}`);
481
+ }
482
+ }
483
+
484
+ if (!args.includes('--no-diff') && prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
429
485
  try {
430
486
  const {apiUrl} = loadConfig();
431
487
  const baseZip = await fetchBlob(apiUrl, app.id, `${prevLatest.packageHash}.zip`);
@@ -434,6 +490,7 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
434
490
  await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/diff`, {
435
491
  method: 'POST',
436
492
  raw: true,
493
+ optional: true,
437
494
  body: diffZip,
438
495
  onUploadProgress: uploadProgress(`uploading ${(diffZip.length / 1e6).toFixed(1)}MB diff`),
439
496
  headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
@@ -455,22 +512,23 @@ function buildOnce(args) {
455
512
  if (privateKeyPath && !fs.existsSync(privateKeyPath)) die(`no signing key at ${privateKeyPath} — drop --private-key to have Pulsar sign`);
456
513
  const localKey = privateKeyPath ? fs.readFileSync(privateKeyPath, 'utf8') : (process.env.PULSAR_SIGNING_KEY || '').trim();
457
514
  const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
458
- bundle(platform, contentsDir);
515
+ const sourcemapPath = args.includes('--no-sourcemap') ? null : `${contentsDir}.map`;
516
+ bundle(platform, contentsDir, sourcemapPath);
459
517
  const packageHash = computePackageHash(contentsDir);
460
518
  console.log(`• hash ${packageHash.slice(0, 12)}…`);
461
- return {contentsDir, packageHash, localKey};
519
+ return {contentsDir, packageHash, localKey, sourcemapPath};
462
520
  }
463
521
 
464
522
  async function cmdRelease(args) {
465
523
  const [nameOrId, deployment = 'Staging'] = args;
466
- 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 "..."]');
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 "..."]');
467
525
  if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
468
526
  const app = await resolveApp(nameOrId, getFlag(args, 'platform', 'ios'));
469
527
  if (app.os !== getFlag(args, 'platform', 'ios')) {
470
528
  console.log(`• note: resolved app "${app.name}" is ${app.os}; bundling ${getFlag(args, 'platform', 'ios')}`);
471
529
  }
472
- const {contentsDir, packageHash, localKey} = buildOnce(args);
473
- await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey);
530
+ const {contentsDir, packageHash, localKey, sourcemapPath} = buildOnce(args);
531
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath});
474
532
  }
475
533
 
476
534
  // Release the same built bundle to several apps at once (shared-codebase / white-label fleets).
@@ -495,11 +553,11 @@ async function cmdReleaseTrain(args) {
495
553
  apps = apps.filter((app) => app.os === platform);
496
554
  if (!apps.length) die(`No ${platform} apps matched.`);
497
555
 
498
- const {contentsDir, packageHash, localKey} = buildOnce(args);
556
+ const {contentsDir, packageHash, localKey, sourcemapPath} = buildOnce(args);
499
557
  console.log(`• release train → ${apps.length} app(s): ${apps.map((app) => app.name).join(', ')}`);
500
558
  for (const app of apps) {
501
559
  try {
502
- await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey);
560
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, localKey, {sourcemapPath});
503
561
  } catch (err) {
504
562
  console.log(`✗ ${app.name}: ${err.message}`);
505
563
  }
@@ -628,7 +686,7 @@ async function cmdKeys(args) {
628
686
  console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
629
687
  }
630
688
 
631
- export {saveConfig, browserLogin, signCodePushJWT, SIGNATURE_PATH};
689
+ export {saveConfig, browserLogin, signCodePushJWT, SIGNATURE_PATH, api, die, getFlag, loadConfig, pushBuiltRelease, splitList};
632
690
 
633
691
  // Only dispatch when run as the CLI — importing the module (e.g. from tests) must not execute a command.
634
692
  // Compare real paths: a global install runs through a bin symlink, so argv[1] is not this file's path.
@@ -654,6 +712,7 @@ if (invokedAsCli()) {
654
712
  patch: cmdPatch,
655
713
  keygen: cmdKeygen,
656
714
  keys: cmdKeys,
715
+ migrate,
657
716
  };
658
717
 
659
718
  const ANONYMOUS_COMMANDS = new Set(['login', 'keygen']);
@@ -663,7 +722,7 @@ if (invokedAsCli()) {
663
722
  }
664
723
 
665
724
  (commands[command] || (() => {
666
- console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen>');
725
+ console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen|migrate>');
667
726
  process.exit(command ? 1 : 0);
668
727
  }))(rest);
669
728
  }