@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.
- package/package.json +3 -3
- 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.
|
|
4
|
-
"description": "Pulsar OTA release CLI
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
105
|
-
if (
|
|
106
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
+
}
|