@novacraft-engineering/pulsar-cli 1.1.1 → 1.2.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 (2) hide show
  1. package/package.json +3 -3
  2. package/pulsar.js +159 -41
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/pulsar-cli",
3
- "version": "1.1.1",
4
- "description": "Pulsar OTA release CLI — bundle, hash, and ship React Native updates over-the-air.",
3
+ "version": "1.2.1",
4
+ "description": "Pulsar OTA release CLI \u2014 bundle, hash, and ship React Native updates over-the-air.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "pulsar": "./pulsar.js"
@@ -26,7 +26,7 @@
26
26
  "node": ">=18"
27
27
  },
28
28
  "scripts": {
29
- "test": "node test/hash.test.js"
29
+ "test": "node test/hash.test.js && node test/security.test.js"
30
30
  },
31
31
  "keywords": [
32
32
  "react-native",
package/pulsar.js CHANGED
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import readline from 'node:readline';
6
+ import http from 'node:http';
6
7
  import {execFileSync} from 'node:child_process';
7
8
  import crypto from 'node:crypto';
8
9
  import {computePackageHash, fileHashes} from './hash.js';
@@ -22,7 +23,7 @@ function cmdKeygen() {
22
23
  publicKeyEncoding: {type: 'spki', format: 'pem'},
23
24
  privateKeyEncoding: {type: 'pkcs8', format: 'pem'},
24
25
  });
25
- fs.writeFileSync('pulsar-private.pem', privateKey);
26
+ fs.writeFileSync('pulsar-private.pem', privateKey, {mode: 0o600});
26
27
  fs.writeFileSync('pulsar-public.pem', publicKey);
27
28
  console.log('✓ wrote pulsar-private.pem (keep secret, use with --private-key) and pulsar-public.pem');
28
29
  console.log(' Put the public key in Info.plist <key>CodePushPublicKey</key> to require signed updates.');
@@ -40,17 +41,20 @@ function loadConfig() {
40
41
  }
41
42
 
42
43
  function saveConfig(config) {
43
- fs.mkdirSync(CONFIG_DIR, {recursive: true});
44
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
44
+ // The config holds a bearer token — keep the dir and file owner-only so other local users on a
45
+ // shared host can't read the session.
46
+ fs.mkdirSync(CONFIG_DIR, {recursive: true, mode: 0o700});
47
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {mode: 0o600});
48
+ try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* best-effort on pre-existing files */ }
45
49
  }
46
50
 
47
- async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous} = {}) {
51
+ async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous, onUploadProgress} = {}) {
48
52
  const {apiUrl, token} = loadConfig();
49
53
  if (!apiUrl) die('No API url. Run: pulsar login <apiUrl>');
50
54
  // Every call except the login handshake must carry a token, so an expired or
51
55
  // cleared session fails here rather than silently issuing anonymous requests.
52
56
  if (!token && !allowAnonymous) die('Not authenticated. Run: pulsar login <apiUrl>');
53
- const response = await fetch(apiUrl.replace(/\/$/, '') + pathname, {
57
+ const options = {
54
58
  method,
55
59
  headers: {
56
60
  ...(token ? {authorization: `Bearer ${token}`} : {}),
@@ -58,7 +62,24 @@ async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnon
58
62
  ...headers,
59
63
  },
60
64
  body: raw ? body : body ? JSON.stringify(body) : undefined,
61
- });
65
+ };
66
+ // Stream a raw upload in chunks so we can report progress as the request drains to the socket.
67
+ if (raw && onUploadProgress && body && body.length) {
68
+ const total = body.length;
69
+ let offset = 0;
70
+ const chunkSize = 64 * 1024;
71
+ options.body = new ReadableStream({
72
+ pull(controller) {
73
+ if (offset >= total) { controller.close(); return; }
74
+ const end = Math.min(offset + chunkSize, total);
75
+ controller.enqueue(body.subarray(offset, end));
76
+ offset = end;
77
+ onUploadProgress(offset, total);
78
+ },
79
+ });
80
+ options.duplex = 'half';
81
+ }
82
+ const response = await fetch(apiUrl.replace(/\/$/, '') + pathname, options);
62
83
  const text = await response.text();
63
84
  // Check the status BEFORE parsing: an edge/proxy error (413, 502, 524) returns
64
85
  // an HTML page, and parsing that first turned every such failure into an
@@ -99,11 +120,81 @@ function prompt(question, hidden = false) {
99
120
  });
100
121
  }
101
122
 
102
- async function resolveApp(nameOrId) {
123
+ // Returns an onUploadProgress callback that renders a single updating line (or plain lines in CI).
124
+ function uploadProgress(label) {
125
+ let lastPct = -1;
126
+ return (sent, total) => {
127
+ const pct = Math.floor((sent / total) * 100);
128
+ if (pct === lastPct && sent < total) return;
129
+ lastPct = pct;
130
+ const line = `• ${label} — ${pct}% (${(sent / 1e6).toFixed(1)}/${(total / 1e6).toFixed(1)} MB)`;
131
+ if (process.stdout.isTTY) {
132
+ process.stdout.write(`\r${line} `);
133
+ if (sent >= total) process.stdout.write('\n');
134
+ } else if (sent >= total) {
135
+ console.log(line);
136
+ }
137
+ };
138
+ }
139
+
140
+ function openBrowser(url) {
141
+ try {
142
+ if (process.platform === 'darwin') execFileSync('open', [url], {stdio: 'ignore'});
143
+ else if (process.platform === 'win32') execFileSync('cmd', ['/c', 'start', '', url], {stdio: 'ignore'});
144
+ else execFileSync('xdg-open', [url], {stdio: 'ignore'});
145
+ } catch { /* the URL is printed too, so the user can open it manually */ }
146
+ }
147
+
148
+ function dashboardUrl(apiUrl) {
149
+ try {
150
+ const parsed = new URL(apiUrl);
151
+ parsed.host = parsed.host.replace(/^api\./, '');
152
+ return `${parsed.protocol}//${parsed.host}`;
153
+ } catch { return apiUrl.replace(/\/$/, ''); }
154
+ }
155
+
156
+ // Browser login: spin up a throwaway localhost server, open the dashboard's /cli-login page which
157
+ // (once you're signed in) redirects back here with a token. No password ever touches the terminal.
158
+ function browserLogin(apiUrl) {
159
+ // A random nonce ties the callback to THIS login: the dashboard echoes it back and we reject any
160
+ // callback whose state doesn't match, so a stray page spraying the loopback port range can't inject
161
+ // its own token into our config (login fixation).
162
+ const state = crypto.randomUUID();
163
+ return new Promise((resolve, reject) => {
164
+ const server = http.createServer((req, res) => {
165
+ const requestUrl = new URL(req.url, 'http://127.0.0.1');
166
+ if (requestUrl.pathname !== '/callback') { res.writeHead(204); res.end(); return; }
167
+ const token = requestUrl.searchParams.get('token');
168
+ const ok = token && requestUrl.searchParams.get('state') === state;
169
+ res.writeHead(ok ? 200 : 400, {'content-type': 'text/html'});
170
+ res.end(`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;background:#080a0f;color:#eef2f7;display:grid;place-items:center;height:100vh;margin:0"><div style="text-align:center"><h2 style="font-weight:600">${ok ? 'Pulsar CLI authorized ✓' : 'Authorization failed'}</h2><p style="color:#8f98a6">You can close this tab and return to your terminal.</p></div></body>`);
171
+ if (!ok) return;
172
+ setTimeout(() => { try { server.close(); } catch {} }, 100);
173
+ resolve(token);
174
+ });
175
+ server.on('error', reject);
176
+ server.listen(0, '127.0.0.1', () => {
177
+ const {port} = server.address();
178
+ const authUrl = `${dashboardUrl(apiUrl)}/cli-login?port=${port}&state=${state}`;
179
+ console.log(`\n Authorize the CLI in your browser:\n ${authUrl}\n`);
180
+ openBrowser(authUrl);
181
+ console.log(' Waiting for authorization…');
182
+ });
183
+ setTimeout(() => { try { server.close(); } catch {} reject(new Error('Login timed out after 3 minutes')); }, 180000);
184
+ });
185
+ }
186
+
187
+ async function resolveApp(nameOrId, platform) {
103
188
  const apps = await api('/apps');
104
- const match = apps.find((app) => app.id === nameOrId || app.name === nameOrId);
105
- if (!match) die(`App not found: ${nameOrId}. Known: ${apps.map((app) => app.name).join(', ') || '(none)'}`);
106
- return match;
189
+ const byId = apps.find((app) => app.id === nameOrId);
190
+ if (byId) return byId;
191
+ const named = apps.filter((app) => app.name === nameOrId);
192
+ if (!named.length) die(`App not found: ${nameOrId}. Known: ${apps.map((app) => `${app.name} [${app.os}]`).join(', ') || '(none)'}`);
193
+ if (named.length === 1) return named[0];
194
+ // Same name for an iOS and an Android app — disambiguate by platform (or the app id).
195
+ const forPlatform = platform && named.find((app) => app.os === platform);
196
+ if (forPlatform) return forPlatform;
197
+ die(`Multiple apps named "${nameOrId}" (${named.map((app) => app.os).join(', ')}). Pass --platform ios|android, or use the app id.`);
107
198
  }
108
199
 
109
200
  function bundle(platform, contentsDir) {
@@ -173,9 +264,26 @@ function getFlag(args, name, fallback) {
173
264
 
174
265
  async function cmdLogin(args) {
175
266
  const apiUrl = args[0] || (await prompt('API url: '));
267
+ saveConfig({apiUrl, token: undefined});
268
+
269
+ let mode;
270
+ if (args.includes('--browser')) mode = 'browser';
271
+ else if (args.includes('--terminal')) mode = 'terminal';
272
+ else {
273
+ const answer = (await prompt('Sign in with [b]rowser or [t]erminal? (b/t): ')).toLowerCase();
274
+ mode = answer.startsWith('t') ? 'terminal' : 'browser';
275
+ }
276
+
277
+ if (mode === 'browser') {
278
+ const token = await browserLogin(apiUrl);
279
+ saveConfig({apiUrl, token});
280
+ const me = await api('/auth/me').catch(() => null);
281
+ console.log(`✓ logged in${me?.email ? ` as ${me.email}` : ''} (browser)`);
282
+ return;
283
+ }
284
+
176
285
  const email = await prompt('Email: ');
177
286
  const password = await prompt('Password: ', true);
178
- saveConfig({apiUrl, token: undefined});
179
287
  const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, allowAnonymous: true});
180
288
  saveConfig({apiUrl, token});
181
289
  console.log(`✓ logged in as ${user.email} (${user.platformRole})`);
@@ -207,6 +315,7 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
207
315
  method: 'POST',
208
316
  raw: true,
209
317
  body: zip,
318
+ onUploadProgress: uploadProgress(`uploading ${(zip.length / 1e6).toFixed(1)}MB bundle`),
210
319
  headers: {
211
320
  'content-type': 'application/zip',
212
321
  'x-app-version': getFlag(args, 'target'),
@@ -231,6 +340,7 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
231
340
  method: 'POST',
232
341
  raw: true,
233
342
  body: diffZip,
343
+ onUploadProgress: uploadProgress(`uploading ${(diffZip.length / 1e6).toFixed(1)}MB diff`),
234
344
  headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
235
345
  });
236
346
  const saved = Math.round((1 - diffZip.length / zip.length) * 100);
@@ -262,7 +372,10 @@ async function cmdRelease(args) {
262
372
  const [nameOrId, deployment = 'Staging'] = args;
263
373
  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
374
  if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
265
- const app = await resolveApp(nameOrId);
375
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform', 'ios'));
376
+ if (app.os !== getFlag(args, 'platform', 'ios')) {
377
+ console.log(`• note: resolved app "${app.name}" is ${app.os}; bundling ${getFlag(args, 'platform', 'ios')}`);
378
+ }
266
379
  const {contentsDir, packageHash, zip} = buildOnce(args);
267
380
  await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
268
381
  }
@@ -302,7 +415,7 @@ async function cmdReleaseTrain(args) {
302
415
 
303
416
  async function cmdPromote(args) {
304
417
  const [nameOrId, from = 'Staging', to = 'Production'] = args;
305
- const app = await resolveApp(nameOrId);
418
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
306
419
  const release = await api(`/apps/${app.id}/deployments/${from}/promote`, {
307
420
  method: 'POST',
308
421
  body: {to, rollout: Number(getFlag(args, 'rollout', '100'))},
@@ -312,14 +425,14 @@ async function cmdPromote(args) {
312
425
 
313
426
  async function cmdRollback(args) {
314
427
  const [nameOrId, deployment = 'Production'] = args;
315
- const app = await resolveApp(nameOrId);
428
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
316
429
  const release = await api(`/apps/${app.id}/deployments/${deployment}/rollback`, {method: 'POST'});
317
430
  console.log(`✓ rolled back ${app.name}/${deployment} → ${release.label}`);
318
431
  }
319
432
 
320
433
  async function cmdDeployment(args) {
321
434
  const [sub, nameOrId, depName] = args;
322
- const app = await resolveApp(nameOrId);
435
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
323
436
  if (sub === 'add') {
324
437
  if (!depName) die('Usage: pulsar deployment add <app> <name>');
325
438
  const created = await api(`/apps/${app.id}/deployments`, {method: 'POST', body: {name: depName}});
@@ -336,7 +449,7 @@ async function cmdDeployment(args) {
336
449
 
337
450
  async function cmdHistory(args) {
338
451
  const [nameOrId, deployment = 'Staging'] = args;
339
- const app = await resolveApp(nameOrId);
452
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
340
453
  const releases = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
341
454
  if (!releases.length) return console.log('(no releases)');
342
455
  for (const release of [...releases].reverse()) {
@@ -348,7 +461,7 @@ async function cmdHistory(args) {
348
461
  async function cmdPatch(args) {
349
462
  const [nameOrId, deployment, label] = args;
350
463
  if (!label) die('Usage: pulsar patch <app> <deployment> <label> [--rollout N] [--mandatory] [--disable] [--enable] [--devices id1,id2,...]');
351
- const app = await resolveApp(nameOrId);
464
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
352
465
  const body = {};
353
466
  const rollout = getFlag(args, 'rollout');
354
467
  if (rollout !== undefined) body.rollout = Number(rollout);
@@ -408,28 +521,33 @@ async function cmdKeys(args) {
408
521
  console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
409
522
  }
410
523
 
411
- const [command, ...rest] = process.argv.slice(2);
412
- const commands = {
413
- login: cmdLogin,
414
- apps: cmdApps,
415
- release: cmdRelease,
416
- 'release-train': cmdReleaseTrain,
417
- promote: cmdPromote,
418
- rollback: cmdRollback,
419
- deployment: cmdDeployment,
420
- history: cmdHistory,
421
- patch: cmdPatch,
422
- keygen: cmdKeygen,
423
- keys: cmdKeys,
424
- };
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
- }
524
+ export {saveConfig, browserLogin};
525
+
526
+ // Only dispatch when run as the CLI — importing the module (e.g. from tests) must not execute a command.
527
+ if (import.meta.url === `file://${process.argv[1]}`) {
528
+ const [command, ...rest] = process.argv.slice(2);
529
+ const commands = {
530
+ login: cmdLogin,
531
+ apps: cmdApps,
532
+ release: cmdRelease,
533
+ 'release-train': cmdReleaseTrain,
534
+ promote: cmdPromote,
535
+ rollback: cmdRollback,
536
+ deployment: cmdDeployment,
537
+ history: cmdHistory,
538
+ patch: cmdPatch,
539
+ keygen: cmdKeygen,
540
+ keys: cmdKeys,
541
+ };
431
542
 
432
- (commands[command] || (() => {
433
- console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen>');
434
- process.exit(command ? 1 : 0);
435
- }))(rest);
543
+ const ANONYMOUS_COMMANDS = new Set(['login', 'keygen']);
544
+
545
+ if (commands[command] && !ANONYMOUS_COMMANDS.has(command) && !loadConfig().token) {
546
+ die('Not authenticated. Run: pulsar login <apiUrl>');
547
+ }
548
+
549
+ (commands[command] || (() => {
550
+ console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen>');
551
+ process.exit(command ? 1 : 0);
552
+ }))(rest);
553
+ }