@novacraft-engineering/pulsar-cli 1.0.0 → 1.1.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.
Files changed (3) hide show
  1. package/hash.js +11 -0
  2. package/package.json +28 -7
  3. package/pulsar.js +211 -24
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.0",
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: {
@@ -92,6 +117,41 @@ function zipContents(contentsDir) {
92
117
  return zipPath;
93
118
  }
94
119
 
120
+ // Build a differential package: only the files that changed vs the base, plus a hotcodepush.json
121
+ // manifest listing deleted files. The client reconstructs the full bundle from its current copy +
122
+ // this diff, then verifies the result against the target hash. Returns the diff dir, or null if the
123
+ // base couldn't be fetched.
124
+ async function buildDiffPackage(baseZip, newDir) {
125
+ const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-base-'));
126
+ const basePath = path.join(os.tmpdir(), `pulsar-base-${Date.now()}.zip`);
127
+ fs.writeFileSync(basePath, baseZip);
128
+ execFileSync('unzip', ['-qq', '-o', basePath, '-d', baseDir]);
129
+
130
+ const baseHashes = fileHashes(baseDir);
131
+ const newHashes = fileHashes(newDir);
132
+ const diffDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-diff-'));
133
+
134
+ for (const [relativePath, hash] of Object.entries(newHashes)) {
135
+ if (baseHashes[relativePath] === hash) continue;
136
+ const dest = path.join(diffDir, relativePath);
137
+ fs.mkdirSync(path.dirname(dest), {recursive: true});
138
+ fs.copyFileSync(path.join(newDir, relativePath), dest);
139
+ }
140
+ const deletedFiles = Object.keys(baseHashes).filter((relativePath) => !(relativePath in newHashes));
141
+ fs.writeFileSync(path.join(diffDir, 'hotcodepush.json'), JSON.stringify({deletedFiles}));
142
+ // The signature isn't part of the content hash, so it's excluded above — but the patched bundle
143
+ // still needs it to verify, so carry it into the diff when the release is signed.
144
+ const signature = path.join(newDir, '.codepushrelease');
145
+ if (fs.existsSync(signature)) fs.copyFileSync(signature, path.join(diffDir, '.codepushrelease'));
146
+ return diffDir;
147
+ }
148
+
149
+ async function fetchBlob(apiUrl, appId, fileName) {
150
+ const response = await fetch(`${apiUrl.replace(/\/$/, '')}/download/${appId}/${fileName}`);
151
+ if (!response.ok) throw new Error(`download ${response.status}`);
152
+ return Buffer.from(await response.arrayBuffer());
153
+ }
154
+
95
155
  function getFlag(args, name, fallback) {
96
156
  const index = args.indexOf(`--${name}`);
97
157
  return index >= 0 ? args[index + 1] : fallback;
@@ -102,7 +162,7 @@ async function cmdLogin(args) {
102
162
  const email = await prompt('Email: ');
103
163
  const password = await prompt('Password: ', true);
104
164
  saveConfig({apiUrl, token: undefined});
105
- const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}});
165
+ const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, allowAnonymous: true});
106
166
  saveConfig({apiUrl, token});
107
167
  console.log(`✓ logged in as ${user.email} (${user.platformRole})`);
108
168
  }
@@ -118,21 +178,16 @@ async function cmdApps() {
118
178
  }
119
179
  }
120
180
 
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`);
181
+ // Push an already-built (and optionally signed) bundle to one app's deployment, then generate and
182
+ // upload a differential patch against whatever that deployment was serving.
183
+ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip) {
184
+ let prevLatest = null;
185
+ if (!args.includes('--no-diff')) {
186
+ try {
187
+ const existing = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
188
+ prevLatest = [...existing].reverse().find((entry) => !entry.isDisabled) || existing[existing.length - 1] || null;
189
+ } catch {}
190
+ }
136
191
 
137
192
  const release = await api(`/apps/${app.id}/deployments/${deployment}/release`, {
138
193
  method: 'POST',
@@ -140,19 +195,95 @@ async function cmdRelease(args) {
140
195
  body: zip,
141
196
  headers: {
142
197
  'content-type': 'application/zip',
143
- 'x-app-version': target,
198
+ 'x-app-version': getFlag(args, 'target'),
144
199
  'x-package-hash': packageHash,
145
200
  'x-is-mandatory': String(args.includes('--mandatory')),
146
201
  'x-disabled': String(args.includes('--disabled')),
147
202
  'x-rollout': String(getFlag(args, 'rollout', '100')),
148
203
  'x-client-ids': getFlag(args, 'devices', ''),
149
- 'x-description': getFlag(args, 'description', ''),
204
+ 'x-description': encodeURIComponent(getFlag(args, 'description', '')),
150
205
  },
151
206
  });
152
- const reach = release.clientIds?.length
153
- ? `${release.clientIds.length} device(s)`
154
- : `${release.rollout}% of devices`;
207
+ const reach = release.clientIds?.length ? `${release.clientIds.length} device(s)` : `${release.rollout}% of devices`;
155
208
  console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}`);
209
+
210
+ if (prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
211
+ try {
212
+ const {apiUrl} = loadConfig();
213
+ const baseZip = await fetchBlob(apiUrl, app.id, `${prevLatest.packageHash}.zip`);
214
+ const diffDir = await buildDiffPackage(baseZip, contentsDir);
215
+ const diffZip = fs.readFileSync(zipContents(diffDir));
216
+ await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/diff`, {
217
+ method: 'POST',
218
+ raw: true,
219
+ body: diffZip,
220
+ headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
221
+ });
222
+ const saved = Math.round((1 - diffZip.length / zip.length) * 100);
223
+ console.log(`• diff vs ${prevLatest.label}: ${(diffZip.length / 1e3).toFixed(0)} KB (${saved}% smaller)`);
224
+ } catch (err) {
225
+ console.log(`• diff skipped (full bundle still serves): ${err.message}`);
226
+ }
227
+ }
228
+ return release;
229
+ }
230
+
231
+ // Build the JS bundle once, sign it if requested, and return the reusable artifacts.
232
+ function buildOnce(args) {
233
+ const platform = getFlag(args, 'platform', 'ios');
234
+ const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
235
+ bundle(platform, contentsDir);
236
+ const packageHash = computePackageHash(contentsDir);
237
+ const privateKeyPath = getFlag(args, 'private-key');
238
+ if (privateKeyPath) {
239
+ fs.writeFileSync(path.join(contentsDir, '.codepushrelease'), signCodePushJWT(fs.readFileSync(privateKeyPath, 'utf8'), packageHash));
240
+ console.log('• signed release — devices with CodePushPublicKey set will verify it');
241
+ }
242
+ const zip = fs.readFileSync(zipContents(contentsDir));
243
+ console.log(`• hash ${packageHash.slice(0, 12)}… size ${(zip.length / 1e6).toFixed(2)} MB`);
244
+ return {contentsDir, packageHash, zip};
245
+ }
246
+
247
+ async function cmdRelease(args) {
248
+ const [nameOrId, deployment = 'Staging'] = args;
249
+ 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 "..."]');
250
+ if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
251
+ const app = await resolveApp(nameOrId);
252
+ const {contentsDir, packageHash, zip} = buildOnce(args);
253
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
254
+ }
255
+
256
+ // Release the same built bundle to several apps at once (shared-codebase / white-label fleets).
257
+ async function cmdReleaseTrain(args) {
258
+ const platform = getFlag(args, 'platform', 'ios');
259
+ if (!getFlag(args, 'target')) die('--target <binary version> is required');
260
+ const orgName = getFlag(args, 'org');
261
+ const all = await api('/apps');
262
+ let apps;
263
+ let deployment;
264
+ if (orgName) {
265
+ deployment = args[0] && !args[0].startsWith('--') ? args[0] : 'Staging';
266
+ const org = (await api('/orgs')).find((entry) => entry.name.toLowerCase() === orgName.toLowerCase());
267
+ if (!org) die(`Organization not found: ${orgName}`);
268
+ apps = all.filter((app) => app.orgId === org.id);
269
+ } else {
270
+ if (!args[0] || args[0].startsWith('--')) die('Usage: pulsar release-train <App1,App2,…|--org Name> <deployment> --platform ios|android --target <ver> [...release flags]');
271
+ deployment = args[1] && !args[1].startsWith('--') ? args[1] : 'Staging';
272
+ const names = args[0].split(',').map((name) => name.trim().toLowerCase());
273
+ apps = all.filter((app) => names.includes(app.name.toLowerCase()) || names.includes(app.id));
274
+ }
275
+ apps = apps.filter((app) => app.os === platform);
276
+ if (!apps.length) die(`No ${platform} apps matched.`);
277
+
278
+ const {contentsDir, packageHash, zip} = buildOnce(args);
279
+ console.log(`• release train → ${apps.length} app(s): ${apps.map((app) => app.name).join(', ')}`);
280
+ for (const app of apps) {
281
+ try {
282
+ await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
283
+ } catch (err) {
284
+ console.log(`✗ ${app.name}: ${err.message}`);
285
+ }
286
+ }
156
287
  }
157
288
 
158
289
  async function cmdPromote(args) {
@@ -216,19 +347,75 @@ async function cmdPatch(args) {
216
347
  console.log(`✓ patched ${label}: rollout ${release.rollout}%${release.isMandatory ? ' mandatory' : ''}${release.isDisabled ? ' disabled' : ''}`);
217
348
  }
218
349
 
350
+ // Deployment keys for a whole React Native project (its -Android and -iOS apps), shaped as the
351
+ // PULSAR_* env vars the mobile clients read. DEV maps to Staging, LIVE maps to Production.
352
+ const KEY_VAR = (platform, deployment) => `PULSAR_${platform.toUpperCase()}_${deployment === 'Production' ? 'LIVE' : 'DEV'}_KEY`;
353
+
354
+ async function cmdKeys(args) {
355
+ const project = args.find((arg) => !arg.startsWith('--'));
356
+ if (!project) die('Usage: pulsar keys <project> [--write <path/to/.env>] [--json]');
357
+ const apps = await api('/apps');
358
+ const matches = apps.filter((app) => app.name.toLowerCase().startsWith(`${project.toLowerCase()}-`));
359
+ if (!matches.length) die(`No apps for project "${project}". Known: ${apps.map((app) => app.name).join(', ')}`);
360
+
361
+ const vars = {};
362
+ for (const app of matches) {
363
+ for (const deployment of ['Staging', 'Production']) {
364
+ const key = app.deployments?.[deployment]?.key;
365
+ if (key) vars[KEY_VAR(app.os, deployment)] = key;
366
+ }
367
+ }
368
+
369
+ const target = getFlag(args, 'write');
370
+ if (!target) {
371
+ if (args.includes('--json')) return console.log(JSON.stringify(vars, null, 2));
372
+ for (const [name, value] of Object.entries(vars)) console.log(`${name}=${value}`);
373
+ return;
374
+ }
375
+
376
+ // Merge into the project's .env: update the PULSAR_* lines in place, append any that are
377
+ // missing, and leave every other line (and every other secret) untouched.
378
+ const existing = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
379
+ const lines = existing.split('\n');
380
+ const written = new Set();
381
+ const merged = lines.map((line) => {
382
+ const match = line.match(/^([A-Z0-9_]+)(\s*=\s*)(.*)$/);
383
+ if (!match || !(match[1] in vars)) return line;
384
+ written.add(match[1]);
385
+ return `${match[1]}${match[2]}${vars[match[1]]}`;
386
+ });
387
+ const missing = Object.keys(vars).filter((name) => !written.has(name));
388
+ if (missing.length) {
389
+ if (merged[merged.length - 1] !== '') merged.push('');
390
+ merged.push('# Pulsar OTA keys (DEV = Staging deployment, LIVE = Production)');
391
+ for (const name of missing) merged.push(`${name}=${vars[name]}`);
392
+ }
393
+ fs.writeFileSync(target, merged.join('\n'));
394
+ console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
395
+ }
396
+
219
397
  const [command, ...rest] = process.argv.slice(2);
220
398
  const commands = {
221
399
  login: cmdLogin,
222
400
  apps: cmdApps,
223
401
  release: cmdRelease,
402
+ 'release-train': cmdReleaseTrain,
224
403
  promote: cmdPromote,
225
404
  rollback: cmdRollback,
226
405
  deployment: cmdDeployment,
227
406
  history: cmdHistory,
228
407
  patch: cmdPatch,
408
+ keygen: cmdKeygen,
409
+ keys: cmdKeys,
229
410
  };
230
411
 
412
+ const ANONYMOUS_COMMANDS = new Set(['login', 'keygen']);
413
+
414
+ if (commands[command] && !ANONYMOUS_COMMANDS.has(command) && !loadConfig().token) {
415
+ die('Not authenticated. Run: pulsar login <apiUrl>');
416
+ }
417
+
231
418
  (commands[command] || (() => {
232
- console.log('pulsar <login|apps|release|promote|rollback|deployment|history|patch>');
419
+ console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen>');
233
420
  process.exit(command ? 1 : 0);
234
421
  }))(rest);