@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.
- package/package.json +1 -1
- package/pulsar.js +117 -14
package/package.json
CHANGED
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
|
|
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
|
-
|
|
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
|
|
105
|
-
if (
|
|
106
|
-
|
|
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);
|