@novacraft-engineering/pulsar-cli 1.0.0 → 1.1.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 (3) hide show
  1. package/hash.js +11 -0
  2. package/package.json +28 -7
  3. package/pulsar.js +228 -27
package/hash.js CHANGED
@@ -23,6 +23,17 @@ function walk(dir) {
23
23
  return files;
24
24
  }
25
25
 
26
+ // Per-file sha256 map (relativePath -> hash), used to compute differential packages.
27
+ export function fileHashes(contentsDir) {
28
+ const map = {};
29
+ for (const file of walk(contentsDir)) {
30
+ const relativePath = path.relative(contentsDir, file).split(path.sep).join('/');
31
+ if (isIgnored(relativePath)) continue;
32
+ map[relativePath] = sha256(fs.readFileSync(file));
33
+ }
34
+ return map;
35
+ }
36
+
26
37
  // Matches @revopush/react-native-code-push CodePushUpdateUtils.computeFinalHashFromManifest:
27
38
  // build "relPath:sha256(file)" entries, sort, JSON.stringify the array, sha256 that string.
28
39
  export function computePackageHash(contentsDir) {
package/package.json CHANGED
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/pulsar-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Pulsar OTA release CLI — bundle, hash, and ship React Native updates over-the-air.",
5
5
  "type": "module",
6
- "bin": {"pulsar": "./pulsar.js"},
7
- "files": ["pulsar.js", "hash.js", "README.md", "LICENSE"],
6
+ "bin": {
7
+ "pulsar": "./pulsar.js"
8
+ },
9
+ "files": [
10
+ "pulsar.js",
11
+ "hash.js",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
8
15
  "repository": {
9
16
  "type": "git",
10
17
  "url": "git+https://github.com/Novacraft-Africa/pulsar-cli.git"
@@ -12,8 +19,22 @@
12
19
  "homepage": "https://pulsar.novacraft.africa",
13
20
  "license": "MIT",
14
21
  "author": "Novacraft Engineering",
15
- "publishConfig": {"access": "public"},
16
- "engines": {"node": ">=18"},
17
- "scripts": {"test": "node test/hash.test.js"},
18
- "keywords": ["react-native", "ota", "code-push", "pulsar", "cli", "release", "hot-update"]
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "scripts": {
29
+ "test": "node test/hash.test.js"
30
+ },
31
+ "keywords": [
32
+ "react-native",
33
+ "ota",
34
+ "code-push",
35
+ "pulsar",
36
+ "cli",
37
+ "release",
38
+ "hot-update"
39
+ ]
19
40
  }
package/pulsar.js CHANGED
@@ -4,7 +4,29 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import readline from 'node:readline';
6
6
  import {execFileSync} from 'node:child_process';
7
- import {computePackageHash} from './hash.js';
7
+ import crypto from 'node:crypto';
8
+ import {computePackageHash, fileHashes} from './hash.js';
9
+
10
+ // Code signing: sign the package hash into an RS256 JWT the client verifies against its
11
+ // CodePushPublicKey (Info.plist). Written to `.codepushrelease` inside the bundle.
12
+ function signCodePushJWT(privateKeyPem, contentHash) {
13
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
14
+ const signingInput = `${encode({alg: 'RS256', typ: 'JWT'})}.${encode({contentHash})}`;
15
+ const signature = crypto.createSign('RSA-SHA256').update(signingInput).sign(privateKeyPem).toString('base64url');
16
+ return `${signingInput}.${signature}`;
17
+ }
18
+
19
+ function cmdKeygen() {
20
+ const {publicKey, privateKey} = crypto.generateKeyPairSync('rsa', {
21
+ modulusLength: 2048,
22
+ publicKeyEncoding: {type: 'spki', format: 'pem'},
23
+ privateKeyEncoding: {type: 'pkcs8', format: 'pem'},
24
+ });
25
+ fs.writeFileSync('pulsar-private.pem', privateKey);
26
+ fs.writeFileSync('pulsar-public.pem', publicKey);
27
+ console.log('✓ wrote pulsar-private.pem (keep secret, use with --private-key) and pulsar-public.pem');
28
+ console.log(' Put the public key in Info.plist <key>CodePushPublicKey</key> to require signed updates.');
29
+ }
8
30
 
9
31
  const CONFIG_DIR = path.join(os.homedir(), '.pulsar');
10
32
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
@@ -22,9 +44,12 @@ function saveConfig(config) {
22
44
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
23
45
  }
24
46
 
25
- async function api(pathname, {method = 'GET', body, headers = {}, raw} = {}) {
47
+ async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous} = {}) {
26
48
  const {apiUrl, token} = loadConfig();
27
49
  if (!apiUrl) die('No API url. Run: pulsar login <apiUrl>');
50
+ // Every call except the login handshake must carry a token, so an expired or
51
+ // cleared session fails here rather than silently issuing anonymous requests.
52
+ if (!token && !allowAnonymous) die('Not authenticated. Run: pulsar login <apiUrl>');
28
53
  const response = await fetch(apiUrl.replace(/\/$/, '') + pathname, {
29
54
  method,
30
55
  headers: {
@@ -35,9 +60,23 @@ async function api(pathname, {method = 'GET', body, headers = {}, raw} = {}) {
35
60
  body: raw ? body : body ? JSON.stringify(body) : undefined,
36
61
  });
37
62
  const text = await response.text();
38
- const data = text ? JSON.parse(text) : {};
39
- if (!response.ok) die(`${response.status}: ${data.error || text}`);
40
- return data;
63
+ // Check the status BEFORE parsing: an edge/proxy error (413, 502, 524) returns
64
+ // an HTML page, and parsing that first turned every such failure into an
65
+ // opaque "Unexpected token '<'" SyntaxError instead of the real status.
66
+ if (!response.ok) {
67
+ let detail = text;
68
+ try {
69
+ detail = JSON.parse(text).error || text;
70
+ } catch {
71
+ detail = text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300);
72
+ }
73
+ die(`${response.status} ${response.statusText}: ${detail}`);
74
+ }
75
+ try {
76
+ return text ? JSON.parse(text) : {};
77
+ } catch {
78
+ die(`${response.status}: expected JSON, got ${text.slice(0, 200)}`);
79
+ }
41
80
  }
42
81
 
43
82
  function die(message) {
@@ -92,6 +131,41 @@ function zipContents(contentsDir) {
92
131
  return zipPath;
93
132
  }
94
133
 
134
+ // Build a differential package: only the files that changed vs the base, plus a hotcodepush.json
135
+ // manifest listing deleted files. The client reconstructs the full bundle from its current copy +
136
+ // this diff, then verifies the result against the target hash. Returns the diff dir, or null if the
137
+ // base couldn't be fetched.
138
+ async function buildDiffPackage(baseZip, newDir) {
139
+ const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-base-'));
140
+ const basePath = path.join(os.tmpdir(), `pulsar-base-${Date.now()}.zip`);
141
+ fs.writeFileSync(basePath, baseZip);
142
+ execFileSync('unzip', ['-qq', '-o', basePath, '-d', baseDir]);
143
+
144
+ const baseHashes = fileHashes(baseDir);
145
+ const newHashes = fileHashes(newDir);
146
+ const diffDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-diff-'));
147
+
148
+ for (const [relativePath, hash] of Object.entries(newHashes)) {
149
+ if (baseHashes[relativePath] === hash) continue;
150
+ const dest = path.join(diffDir, relativePath);
151
+ fs.mkdirSync(path.dirname(dest), {recursive: true});
152
+ fs.copyFileSync(path.join(newDir, relativePath), dest);
153
+ }
154
+ const deletedFiles = Object.keys(baseHashes).filter((relativePath) => !(relativePath in newHashes));
155
+ fs.writeFileSync(path.join(diffDir, 'hotcodepush.json'), JSON.stringify({deletedFiles}));
156
+ // The signature isn't part of the content hash, so it's excluded above — but the patched bundle
157
+ // still needs it to verify, so carry it into the diff when the release is signed.
158
+ const signature = path.join(newDir, '.codepushrelease');
159
+ if (fs.existsSync(signature)) fs.copyFileSync(signature, path.join(diffDir, '.codepushrelease'));
160
+ return diffDir;
161
+ }
162
+
163
+ async function fetchBlob(apiUrl, appId, fileName) {
164
+ const response = await fetch(`${apiUrl.replace(/\/$/, '')}/download/${appId}/${fileName}`);
165
+ if (!response.ok) throw new Error(`download ${response.status}`);
166
+ return Buffer.from(await response.arrayBuffer());
167
+ }
168
+
95
169
  function getFlag(args, name, fallback) {
96
170
  const index = args.indexOf(`--${name}`);
97
171
  return index >= 0 ? args[index + 1] : fallback;
@@ -102,7 +176,7 @@ async function cmdLogin(args) {
102
176
  const email = await prompt('Email: ');
103
177
  const password = await prompt('Password: ', true);
104
178
  saveConfig({apiUrl, token: undefined});
105
- const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}});
179
+ const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, allowAnonymous: true});
106
180
  saveConfig({apiUrl, token});
107
181
  console.log(`✓ logged in as ${user.email} (${user.platformRole})`);
108
182
  }
@@ -118,21 +192,16 @@ async function cmdApps() {
118
192
  }
119
193
  }
120
194
 
121
- async function cmdRelease(args) {
122
- const [nameOrId, deployment = 'Staging'] = args;
123
- if (!nameOrId) die('Usage: pulsar release <app> <Staging|Production> --platform ios|android --target <ver> [--mandatory] [--disabled] [--rollout N] [--devices id1,id2,...] [--description "..."]');
124
- const platform = getFlag(args, 'platform', 'ios');
125
- const target = getFlag(args, 'target');
126
- if (!target) die('--target <binary version> is required (e.g. --target 1.0.0)');
127
-
128
- const app = await resolveApp(nameOrId);
129
- const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
130
- bundle(platform, contentsDir);
131
-
132
- const packageHash = computePackageHash(contentsDir);
133
- const zipPath = zipContents(contentsDir);
134
- const zip = fs.readFileSync(zipPath);
135
- console.log(`• hash ${packageHash.slice(0, 12)}… size ${(zip.length / 1e6).toFixed(2)} MB`);
195
+ // Push an already-built (and optionally signed) bundle to one app's deployment, then generate and
196
+ // upload a differential patch against whatever that deployment was serving.
197
+ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip) {
198
+ let prevLatest = null;
199
+ if (!args.includes('--no-diff')) {
200
+ try {
201
+ const existing = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
202
+ prevLatest = [...existing].reverse().find((entry) => !entry.isDisabled) || existing[existing.length - 1] || null;
203
+ } catch {}
204
+ }
136
205
 
137
206
  const release = await api(`/apps/${app.id}/deployments/${deployment}/release`, {
138
207
  method: 'POST',
@@ -140,19 +209,95 @@ async function cmdRelease(args) {
140
209
  body: zip,
141
210
  headers: {
142
211
  'content-type': 'application/zip',
143
- 'x-app-version': target,
212
+ 'x-app-version': getFlag(args, 'target'),
144
213
  'x-package-hash': packageHash,
145
214
  'x-is-mandatory': String(args.includes('--mandatory')),
146
215
  'x-disabled': String(args.includes('--disabled')),
147
216
  'x-rollout': String(getFlag(args, 'rollout', '100')),
148
217
  'x-client-ids': getFlag(args, 'devices', ''),
149
- 'x-description': getFlag(args, 'description', ''),
218
+ 'x-description': encodeURIComponent(getFlag(args, 'description', '')),
150
219
  },
151
220
  });
152
- const reach = release.clientIds?.length
153
- ? `${release.clientIds.length} device(s)`
154
- : `${release.rollout}% of devices`;
221
+ const reach = release.clientIds?.length ? `${release.clientIds.length} device(s)` : `${release.rollout}% of devices`;
155
222
  console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}`);
223
+
224
+ if (prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
225
+ try {
226
+ const {apiUrl} = loadConfig();
227
+ const baseZip = await fetchBlob(apiUrl, app.id, `${prevLatest.packageHash}.zip`);
228
+ const diffDir = await buildDiffPackage(baseZip, contentsDir);
229
+ const diffZip = fs.readFileSync(zipContents(diffDir));
230
+ await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/diff`, {
231
+ method: 'POST',
232
+ raw: true,
233
+ body: diffZip,
234
+ headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
235
+ });
236
+ const saved = Math.round((1 - diffZip.length / zip.length) * 100);
237
+ console.log(`• diff vs ${prevLatest.label}: ${(diffZip.length / 1e3).toFixed(0)} KB (${saved}% smaller)`);
238
+ } catch (err) {
239
+ console.log(`• diff skipped (full bundle still serves): ${err.message}`);
240
+ }
241
+ }
242
+ return release;
243
+ }
244
+
245
+ // Build the JS bundle once, sign it if requested, and return the reusable artifacts.
246
+ function buildOnce(args) {
247
+ const platform = getFlag(args, 'platform', 'ios');
248
+ const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
249
+ bundle(platform, contentsDir);
250
+ const packageHash = computePackageHash(contentsDir);
251
+ const privateKeyPath = getFlag(args, 'private-key');
252
+ if (privateKeyPath) {
253
+ fs.writeFileSync(path.join(contentsDir, '.codepushrelease'), signCodePushJWT(fs.readFileSync(privateKeyPath, 'utf8'), packageHash));
254
+ console.log('• signed release — devices with CodePushPublicKey set will verify it');
255
+ }
256
+ const zip = fs.readFileSync(zipContents(contentsDir));
257
+ console.log(`• hash ${packageHash.slice(0, 12)}… size ${(zip.length / 1e6).toFixed(2)} MB`);
258
+ return {contentsDir, packageHash, zip};
259
+ }
260
+
261
+ async function cmdRelease(args) {
262
+ const [nameOrId, deployment = 'Staging'] = args;
263
+ if (!nameOrId) die('Usage: pulsar release <app> <Staging|Production> --platform ios|android --target <ver> [--mandatory] [--disabled] [--rollout N] [--devices id1,id2,...] [--private-key key.pem] [--no-diff] [--description "..."]');
264
+ if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
265
+ const app = await resolveApp(nameOrId);
266
+ const {contentsDir, packageHash, zip} = buildOnce(args);
267
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
268
+ }
269
+
270
+ // Release the same built bundle to several apps at once (shared-codebase / white-label fleets).
271
+ async function cmdReleaseTrain(args) {
272
+ const platform = getFlag(args, 'platform', 'ios');
273
+ if (!getFlag(args, 'target')) die('--target <binary version> is required');
274
+ const orgName = getFlag(args, 'org');
275
+ const all = await api('/apps');
276
+ let apps;
277
+ let deployment;
278
+ if (orgName) {
279
+ deployment = args[0] && !args[0].startsWith('--') ? args[0] : 'Staging';
280
+ const org = (await api('/orgs')).find((entry) => entry.name.toLowerCase() === orgName.toLowerCase());
281
+ if (!org) die(`Organization not found: ${orgName}`);
282
+ apps = all.filter((app) => app.orgId === org.id);
283
+ } else {
284
+ if (!args[0] || args[0].startsWith('--')) die('Usage: pulsar release-train <App1,App2,…|--org Name> <deployment> --platform ios|android --target <ver> [...release flags]');
285
+ deployment = args[1] && !args[1].startsWith('--') ? args[1] : 'Staging';
286
+ const names = args[0].split(',').map((name) => name.trim().toLowerCase());
287
+ apps = all.filter((app) => names.includes(app.name.toLowerCase()) || names.includes(app.id));
288
+ }
289
+ apps = apps.filter((app) => app.os === platform);
290
+ if (!apps.length) die(`No ${platform} apps matched.`);
291
+
292
+ const {contentsDir, packageHash, zip} = buildOnce(args);
293
+ console.log(`• release train → ${apps.length} app(s): ${apps.map((app) => app.name).join(', ')}`);
294
+ for (const app of apps) {
295
+ try {
296
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
297
+ } catch (err) {
298
+ console.log(`✗ ${app.name}: ${err.message}`);
299
+ }
300
+ }
156
301
  }
157
302
 
158
303
  async function cmdPromote(args) {
@@ -216,19 +361,75 @@ async function cmdPatch(args) {
216
361
  console.log(`✓ patched ${label}: rollout ${release.rollout}%${release.isMandatory ? ' mandatory' : ''}${release.isDisabled ? ' disabled' : ''}`);
217
362
  }
218
363
 
364
+ // Deployment keys for a whole React Native project (its -Android and -iOS apps), shaped as the
365
+ // PULSAR_* env vars the mobile clients read. DEV maps to Staging, LIVE maps to Production.
366
+ const KEY_VAR = (platform, deployment) => `PULSAR_${platform.toUpperCase()}_${deployment === 'Production' ? 'LIVE' : 'DEV'}_KEY`;
367
+
368
+ async function cmdKeys(args) {
369
+ const project = args.find((arg) => !arg.startsWith('--'));
370
+ if (!project) die('Usage: pulsar keys <project> [--write <path/to/.env>] [--json]');
371
+ const apps = await api('/apps');
372
+ const matches = apps.filter((app) => app.name.toLowerCase().startsWith(`${project.toLowerCase()}-`));
373
+ if (!matches.length) die(`No apps for project "${project}". Known: ${apps.map((app) => app.name).join(', ')}`);
374
+
375
+ const vars = {};
376
+ for (const app of matches) {
377
+ for (const deployment of ['Staging', 'Production']) {
378
+ const key = app.deployments?.[deployment]?.key;
379
+ if (key) vars[KEY_VAR(app.os, deployment)] = key;
380
+ }
381
+ }
382
+
383
+ const target = getFlag(args, 'write');
384
+ if (!target) {
385
+ if (args.includes('--json')) return console.log(JSON.stringify(vars, null, 2));
386
+ for (const [name, value] of Object.entries(vars)) console.log(`${name}=${value}`);
387
+ return;
388
+ }
389
+
390
+ // Merge into the project's .env: update the PULSAR_* lines in place, append any that are
391
+ // missing, and leave every other line (and every other secret) untouched.
392
+ const existing = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
393
+ const lines = existing.split('\n');
394
+ const written = new Set();
395
+ const merged = lines.map((line) => {
396
+ const match = line.match(/^([A-Z0-9_]+)(\s*=\s*)(.*)$/);
397
+ if (!match || !(match[1] in vars)) return line;
398
+ written.add(match[1]);
399
+ return `${match[1]}${match[2]}${vars[match[1]]}`;
400
+ });
401
+ const missing = Object.keys(vars).filter((name) => !written.has(name));
402
+ if (missing.length) {
403
+ if (merged[merged.length - 1] !== '') merged.push('');
404
+ merged.push('# Pulsar OTA keys (DEV = Staging deployment, LIVE = Production)');
405
+ for (const name of missing) merged.push(`${name}=${vars[name]}`);
406
+ }
407
+ fs.writeFileSync(target, merged.join('\n'));
408
+ console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
409
+ }
410
+
219
411
  const [command, ...rest] = process.argv.slice(2);
220
412
  const commands = {
221
413
  login: cmdLogin,
222
414
  apps: cmdApps,
223
415
  release: cmdRelease,
416
+ 'release-train': cmdReleaseTrain,
224
417
  promote: cmdPromote,
225
418
  rollback: cmdRollback,
226
419
  deployment: cmdDeployment,
227
420
  history: cmdHistory,
228
421
  patch: cmdPatch,
422
+ keygen: cmdKeygen,
423
+ keys: cmdKeys,
229
424
  };
230
425
 
426
+ const ANONYMOUS_COMMANDS = new Set(['login', 'keygen']);
427
+
428
+ if (commands[command] && !ANONYMOUS_COMMANDS.has(command) && !loadConfig().token) {
429
+ die('Not authenticated. Run: pulsar login <apiUrl>');
430
+ }
431
+
231
432
  (commands[command] || (() => {
232
- console.log('pulsar <login|apps|release|promote|rollback|deployment|history|patch>');
433
+ console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen>');
233
434
  process.exit(command ? 1 : 0);
234
435
  }))(rest);