@novacraft-engineering/pulsar-cli 1.1.1 → 1.2.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 (2) hide show
  1. package/package.json +1 -1
  2. package/pulsar.js +117 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/pulsar-cli",
3
- "version": "1.1.1",
3
+ "version": "1.2.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": {
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';
@@ -44,13 +45,13 @@ function saveConfig(config) {
44
45
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
45
46
  }
46
47
 
47
- async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous} = {}) {
48
+ async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous, onUploadProgress} = {}) {
48
49
  const {apiUrl, token} = loadConfig();
49
50
  if (!apiUrl) die('No API url. Run: pulsar login <apiUrl>');
50
51
  // Every call except the login handshake must carry a token, so an expired or
51
52
  // cleared session fails here rather than silently issuing anonymous requests.
52
53
  if (!token && !allowAnonymous) die('Not authenticated. Run: pulsar login <apiUrl>');
53
- const response = await fetch(apiUrl.replace(/\/$/, '') + pathname, {
54
+ const options = {
54
55
  method,
55
56
  headers: {
56
57
  ...(token ? {authorization: `Bearer ${token}`} : {}),
@@ -58,7 +59,24 @@ async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnon
58
59
  ...headers,
59
60
  },
60
61
  body: raw ? body : body ? JSON.stringify(body) : undefined,
61
- });
62
+ };
63
+ // Stream a raw upload in chunks so we can report progress as the request drains to the socket.
64
+ if (raw && onUploadProgress && body && body.length) {
65
+ const total = body.length;
66
+ let offset = 0;
67
+ const chunkSize = 64 * 1024;
68
+ options.body = new ReadableStream({
69
+ pull(controller) {
70
+ if (offset >= total) { controller.close(); return; }
71
+ const end = Math.min(offset + chunkSize, total);
72
+ controller.enqueue(body.subarray(offset, end));
73
+ offset = end;
74
+ onUploadProgress(offset, total);
75
+ },
76
+ });
77
+ options.duplex = 'half';
78
+ }
79
+ const response = await fetch(apiUrl.replace(/\/$/, '') + pathname, options);
62
80
  const text = await response.text();
63
81
  // Check the status BEFORE parsing: an edge/proxy error (413, 502, 524) returns
64
82
  // an HTML page, and parsing that first turned every such failure into an
@@ -99,11 +117,74 @@ function prompt(question, hidden = false) {
99
117
  });
100
118
  }
101
119
 
102
- async function resolveApp(nameOrId) {
120
+ // Returns an onUploadProgress callback that renders a single updating line (or plain lines in CI).
121
+ function uploadProgress(label) {
122
+ let lastPct = -1;
123
+ return (sent, total) => {
124
+ const pct = Math.floor((sent / total) * 100);
125
+ if (pct === lastPct && sent < total) return;
126
+ lastPct = pct;
127
+ const line = `• ${label} — ${pct}% (${(sent / 1e6).toFixed(1)}/${(total / 1e6).toFixed(1)} MB)`;
128
+ if (process.stdout.isTTY) {
129
+ process.stdout.write(`\r${line} `);
130
+ if (sent >= total) process.stdout.write('\n');
131
+ } else if (sent >= total) {
132
+ console.log(line);
133
+ }
134
+ };
135
+ }
136
+
137
+ function openBrowser(url) {
138
+ try {
139
+ if (process.platform === 'darwin') execFileSync('open', [url], {stdio: 'ignore'});
140
+ else if (process.platform === 'win32') execFileSync('cmd', ['/c', 'start', '', url], {stdio: 'ignore'});
141
+ else execFileSync('xdg-open', [url], {stdio: 'ignore'});
142
+ } catch { /* the URL is printed too, so the user can open it manually */ }
143
+ }
144
+
145
+ function dashboardUrl(apiUrl) {
146
+ try {
147
+ const parsed = new URL(apiUrl);
148
+ parsed.host = parsed.host.replace(/^api\./, '');
149
+ return `${parsed.protocol}//${parsed.host}`;
150
+ } catch { return apiUrl.replace(/\/$/, ''); }
151
+ }
152
+
153
+ // Browser login: spin up a throwaway localhost server, open the dashboard's /cli-login page which
154
+ // (once you're signed in) redirects back here with a token. No password ever touches the terminal.
155
+ function browserLogin(apiUrl) {
156
+ return new Promise((resolve, reject) => {
157
+ const server = http.createServer((req, res) => {
158
+ const requestUrl = new URL(req.url, 'http://127.0.0.1');
159
+ const token = requestUrl.searchParams.get('token');
160
+ res.writeHead(token ? 200 : 400, {'content-type': 'text/html'});
161
+ 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">${token ? 'Pulsar CLI authorized ✓' : 'No token received'}</h2><p style="color:#8f98a6">You can close this tab and return to your terminal.</p></div></body>`);
162
+ setTimeout(() => { try { server.close(); } catch {} }, 100);
163
+ if (token) resolve(token); else reject(new Error('No token received from the browser'));
164
+ });
165
+ server.on('error', reject);
166
+ server.listen(0, '127.0.0.1', () => {
167
+ const {port} = server.address();
168
+ const authUrl = `${dashboardUrl(apiUrl)}/cli-login?port=${port}`;
169
+ console.log(`\n Authorize the CLI in your browser:\n ${authUrl}\n`);
170
+ openBrowser(authUrl);
171
+ console.log(' Waiting for authorization…');
172
+ });
173
+ setTimeout(() => { try { server.close(); } catch {} reject(new Error('Login timed out after 3 minutes')); }, 180000);
174
+ });
175
+ }
176
+
177
+ async function resolveApp(nameOrId, platform) {
103
178
  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;
179
+ const byId = apps.find((app) => app.id === nameOrId);
180
+ if (byId) return byId;
181
+ const named = apps.filter((app) => app.name === nameOrId);
182
+ if (!named.length) die(`App not found: ${nameOrId}. Known: ${apps.map((app) => `${app.name} [${app.os}]`).join(', ') || '(none)'}`);
183
+ if (named.length === 1) return named[0];
184
+ // Same name for an iOS and an Android app — disambiguate by platform (or the app id).
185
+ const forPlatform = platform && named.find((app) => app.os === platform);
186
+ if (forPlatform) return forPlatform;
187
+ die(`Multiple apps named "${nameOrId}" (${named.map((app) => app.os).join(', ')}). Pass --platform ios|android, or use the app id.`);
107
188
  }
108
189
 
109
190
  function bundle(platform, contentsDir) {
@@ -173,9 +254,26 @@ function getFlag(args, name, fallback) {
173
254
 
174
255
  async function cmdLogin(args) {
175
256
  const apiUrl = args[0] || (await prompt('API url: '));
257
+ saveConfig({apiUrl, token: undefined});
258
+
259
+ let mode;
260
+ if (args.includes('--browser')) mode = 'browser';
261
+ else if (args.includes('--terminal')) mode = 'terminal';
262
+ else {
263
+ const answer = (await prompt('Sign in with [b]rowser or [t]erminal? (b/t): ')).toLowerCase();
264
+ mode = answer.startsWith('t') ? 'terminal' : 'browser';
265
+ }
266
+
267
+ if (mode === 'browser') {
268
+ const token = await browserLogin(apiUrl);
269
+ saveConfig({apiUrl, token});
270
+ const me = await api('/auth/me').catch(() => null);
271
+ console.log(`✓ logged in${me?.email ? ` as ${me.email}` : ''} (browser)`);
272
+ return;
273
+ }
274
+
176
275
  const email = await prompt('Email: ');
177
276
  const password = await prompt('Password: ', true);
178
- saveConfig({apiUrl, token: undefined});
179
277
  const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, allowAnonymous: true});
180
278
  saveConfig({apiUrl, token});
181
279
  console.log(`✓ logged in as ${user.email} (${user.platformRole})`);
@@ -207,6 +305,7 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
207
305
  method: 'POST',
208
306
  raw: true,
209
307
  body: zip,
308
+ onUploadProgress: uploadProgress(`uploading ${(zip.length / 1e6).toFixed(1)}MB bundle`),
210
309
  headers: {
211
310
  'content-type': 'application/zip',
212
311
  'x-app-version': getFlag(args, 'target'),
@@ -231,6 +330,7 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
231
330
  method: 'POST',
232
331
  raw: true,
233
332
  body: diffZip,
333
+ onUploadProgress: uploadProgress(`uploading ${(diffZip.length / 1e6).toFixed(1)}MB diff`),
234
334
  headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
235
335
  });
236
336
  const saved = Math.round((1 - diffZip.length / zip.length) * 100);
@@ -262,7 +362,10 @@ async function cmdRelease(args) {
262
362
  const [nameOrId, deployment = 'Staging'] = args;
263
363
  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
364
  if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
265
- const app = await resolveApp(nameOrId);
365
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform', 'ios'));
366
+ if (app.os !== getFlag(args, 'platform', 'ios')) {
367
+ console.log(`• note: resolved app "${app.name}" is ${app.os}; bundling ${getFlag(args, 'platform', 'ios')}`);
368
+ }
266
369
  const {contentsDir, packageHash, zip} = buildOnce(args);
267
370
  await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
268
371
  }
@@ -302,7 +405,7 @@ async function cmdReleaseTrain(args) {
302
405
 
303
406
  async function cmdPromote(args) {
304
407
  const [nameOrId, from = 'Staging', to = 'Production'] = args;
305
- const app = await resolveApp(nameOrId);
408
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
306
409
  const release = await api(`/apps/${app.id}/deployments/${from}/promote`, {
307
410
  method: 'POST',
308
411
  body: {to, rollout: Number(getFlag(args, 'rollout', '100'))},
@@ -312,14 +415,14 @@ async function cmdPromote(args) {
312
415
 
313
416
  async function cmdRollback(args) {
314
417
  const [nameOrId, deployment = 'Production'] = args;
315
- const app = await resolveApp(nameOrId);
418
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
316
419
  const release = await api(`/apps/${app.id}/deployments/${deployment}/rollback`, {method: 'POST'});
317
420
  console.log(`✓ rolled back ${app.name}/${deployment} → ${release.label}`);
318
421
  }
319
422
 
320
423
  async function cmdDeployment(args) {
321
424
  const [sub, nameOrId, depName] = args;
322
- const app = await resolveApp(nameOrId);
425
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
323
426
  if (sub === 'add') {
324
427
  if (!depName) die('Usage: pulsar deployment add <app> <name>');
325
428
  const created = await api(`/apps/${app.id}/deployments`, {method: 'POST', body: {name: depName}});
@@ -336,7 +439,7 @@ async function cmdDeployment(args) {
336
439
 
337
440
  async function cmdHistory(args) {
338
441
  const [nameOrId, deployment = 'Staging'] = args;
339
- const app = await resolveApp(nameOrId);
442
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
340
443
  const releases = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
341
444
  if (!releases.length) return console.log('(no releases)');
342
445
  for (const release of [...releases].reverse()) {
@@ -348,7 +451,7 @@ async function cmdHistory(args) {
348
451
  async function cmdPatch(args) {
349
452
  const [nameOrId, deployment, label] = args;
350
453
  if (!label) die('Usage: pulsar patch <app> <deployment> <label> [--rollout N] [--mandatory] [--disable] [--enable] [--devices id1,id2,...]');
351
- const app = await resolveApp(nameOrId);
454
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
352
455
  const body = {};
353
456
  const rollout = getFlag(args, 'rollout');
354
457
  if (rollout !== undefined) body.rollout = Number(rollout);