@novacraft-engineering/pulsar-cli 1.1.0 → 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 +134 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/pulsar-cli",
3
- "version": "1.1.0",
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,11 +59,42 @@ 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
- const data = text ? JSON.parse(text) : {};
64
- if (!response.ok) die(`${response.status}: ${data.error || text}`);
65
- return data;
81
+ // Check the status BEFORE parsing: an edge/proxy error (413, 502, 524) returns
82
+ // an HTML page, and parsing that first turned every such failure into an
83
+ // opaque "Unexpected token '<'" SyntaxError instead of the real status.
84
+ if (!response.ok) {
85
+ let detail = text;
86
+ try {
87
+ detail = JSON.parse(text).error || text;
88
+ } catch {
89
+ detail = text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300);
90
+ }
91
+ die(`${response.status} ${response.statusText}: ${detail}`);
92
+ }
93
+ try {
94
+ return text ? JSON.parse(text) : {};
95
+ } catch {
96
+ die(`${response.status}: expected JSON, got ${text.slice(0, 200)}`);
97
+ }
66
98
  }
67
99
 
68
100
  function die(message) {
@@ -85,11 +117,74 @@ function prompt(question, hidden = false) {
85
117
  });
86
118
  }
87
119
 
88
- 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) {
89
178
  const apps = await api('/apps');
90
- const match = apps.find((app) => app.id === nameOrId || app.name === nameOrId);
91
- if (!match) die(`App not found: ${nameOrId}. Known: ${apps.map((app) => app.name).join(', ') || '(none)'}`);
92
- 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.`);
93
188
  }
94
189
 
95
190
  function bundle(platform, contentsDir) {
@@ -159,9 +254,26 @@ function getFlag(args, name, fallback) {
159
254
 
160
255
  async function cmdLogin(args) {
161
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
+
162
275
  const email = await prompt('Email: ');
163
276
  const password = await prompt('Password: ', true);
164
- saveConfig({apiUrl, token: undefined});
165
277
  const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, allowAnonymous: true});
166
278
  saveConfig({apiUrl, token});
167
279
  console.log(`✓ logged in as ${user.email} (${user.platformRole})`);
@@ -193,6 +305,7 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
193
305
  method: 'POST',
194
306
  raw: true,
195
307
  body: zip,
308
+ onUploadProgress: uploadProgress(`uploading ${(zip.length / 1e6).toFixed(1)}MB bundle`),
196
309
  headers: {
197
310
  'content-type': 'application/zip',
198
311
  'x-app-version': getFlag(args, 'target'),
@@ -217,6 +330,7 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
217
330
  method: 'POST',
218
331
  raw: true,
219
332
  body: diffZip,
333
+ onUploadProgress: uploadProgress(`uploading ${(diffZip.length / 1e6).toFixed(1)}MB diff`),
220
334
  headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
221
335
  });
222
336
  const saved = Math.round((1 - diffZip.length / zip.length) * 100);
@@ -248,7 +362,10 @@ async function cmdRelease(args) {
248
362
  const [nameOrId, deployment = 'Staging'] = args;
249
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 "..."]');
250
364
  if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
251
- 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
+ }
252
369
  const {contentsDir, packageHash, zip} = buildOnce(args);
253
370
  await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
254
371
  }
@@ -288,7 +405,7 @@ async function cmdReleaseTrain(args) {
288
405
 
289
406
  async function cmdPromote(args) {
290
407
  const [nameOrId, from = 'Staging', to = 'Production'] = args;
291
- const app = await resolveApp(nameOrId);
408
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
292
409
  const release = await api(`/apps/${app.id}/deployments/${from}/promote`, {
293
410
  method: 'POST',
294
411
  body: {to, rollout: Number(getFlag(args, 'rollout', '100'))},
@@ -298,14 +415,14 @@ async function cmdPromote(args) {
298
415
 
299
416
  async function cmdRollback(args) {
300
417
  const [nameOrId, deployment = 'Production'] = args;
301
- const app = await resolveApp(nameOrId);
418
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
302
419
  const release = await api(`/apps/${app.id}/deployments/${deployment}/rollback`, {method: 'POST'});
303
420
  console.log(`✓ rolled back ${app.name}/${deployment} → ${release.label}`);
304
421
  }
305
422
 
306
423
  async function cmdDeployment(args) {
307
424
  const [sub, nameOrId, depName] = args;
308
- const app = await resolveApp(nameOrId);
425
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
309
426
  if (sub === 'add') {
310
427
  if (!depName) die('Usage: pulsar deployment add <app> <name>');
311
428
  const created = await api(`/apps/${app.id}/deployments`, {method: 'POST', body: {name: depName}});
@@ -322,7 +439,7 @@ async function cmdDeployment(args) {
322
439
 
323
440
  async function cmdHistory(args) {
324
441
  const [nameOrId, deployment = 'Staging'] = args;
325
- const app = await resolveApp(nameOrId);
442
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
326
443
  const releases = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
327
444
  if (!releases.length) return console.log('(no releases)');
328
445
  for (const release of [...releases].reverse()) {
@@ -334,7 +451,7 @@ async function cmdHistory(args) {
334
451
  async function cmdPatch(args) {
335
452
  const [nameOrId, deployment, label] = args;
336
453
  if (!label) die('Usage: pulsar patch <app> <deployment> <label> [--rollout N] [--mandatory] [--disable] [--enable] [--devices id1,id2,...]');
337
- const app = await resolveApp(nameOrId);
454
+ const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
338
455
  const body = {};
339
456
  const rollout = getFlag(args, 'rollout');
340
457
  if (rollout !== undefined) body.rollout = Number(rollout);