@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.
- package/package.json +1 -1
- package/pulsar.js +134 -17
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,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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
if (
|
|
92
|
-
|
|
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);
|