@deeeed/metamask-harness 0.28.0 → 0.29.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 (52) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +41 -0
  3. package/adapters/extension/build-lavamoat.sh +2 -1
  4. package/adapters/extension/ensure-browser.sh +82 -9
  5. package/adapters/extension/inject.mjs +1 -0
  6. package/adapters/extension/launch-browser.cjs +83 -1
  7. package/adapters/extension/lib/chrome-args.cjs +325 -1
  8. package/adapters/extension/lib/playwright-cdp.cjs +34 -0
  9. package/adapters/extension/lib/slot-title.cjs +2 -4
  10. package/adapters/extension/lib/validation-launch-supervisor.cjs +292 -0
  11. package/adapters/extension/lib/validation-process-ownership.cjs +69 -0
  12. package/adapters/extension/reattach.sh +2 -1
  13. package/adapters/extension/sidepanel-toggle.sh +14 -96
  14. package/adapters/extension/wallet-fixture-state.cjs +8 -31
  15. package/adapters/manifest.json +16 -0
  16. package/adapters/shared/private-atomic-write.cjs +47 -0
  17. package/adapters/shared/setup-base.sh +864 -0
  18. package/dist/adapters/extension/runtime.js +367 -24
  19. package/dist/adapters/extension/validation-process-ownership.js +10 -0
  20. package/dist/cli-commands.js +1 -0
  21. package/dist/command-contract.js +12 -0
  22. package/dist/commands/launch/extension.js +130 -19
  23. package/dist/commands/setup-base.js +24 -0
  24. package/dist/mm-harness-cli.js +28 -2
  25. package/library/actions/extension/analytics/consent.mjs +203 -0
  26. package/library/actions/extension/analytics/set_consent.mjs +19 -143
  27. package/library/actions/extension/perps/perps.mjs +2 -16
  28. package/library/actions/extension/perps/state.mjs +20 -0
  29. package/library/actions/extension/wallet/list_accounts.mjs +3 -25
  30. package/library/actions/extension/wallet/read_state.mjs +3 -23
  31. package/library/actions/extension/wallet/select_account.mjs +6 -33
  32. package/library/actions/extension/wallet/setup.mjs +2 -20
  33. package/library/actions/extension/wallet/state.mjs +111 -0
  34. package/library/recipes/runner/action-validation.extension.recipe.json +1 -1
  35. package/library/recipes/runner/action-validation.mobile.recipe.json +1 -1
  36. package/package.json +7 -4
  37. package/scripts/site-contrast.mjs +538 -0
  38. package/site/architecture.html +415 -0
  39. package/site/assets/progress.mjs +272 -0
  40. package/site/assets/style.css +808 -0
  41. package/site/cheatsheet.html +305 -0
  42. package/site/index.html +643 -0
  43. package/site/recipes.html +396 -0
  44. package/site/reviewers.html +374 -0
  45. package/site/tutorials/index.html +180 -0
  46. package/site/tutorials/v1.html +211 -0
  47. package/site/tutorials/v2.html +207 -0
  48. package/site/tutorials/v3.html +214 -0
  49. package/site/tutorials/v4.html +195 -0
  50. package/site/tutorials/v5.html +163 -0
  51. package/site/tutorials/v6.html +165 -0
  52. package/site/tutorials/v7.html +184 -0
@@ -0,0 +1,538 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Browser acceptance and WCAG contrast gate for site/.
4
+ *
5
+ * Serves site/ locally, drives headless Chrome over CDP, and measures every
6
+ * element that renders its own text: the computed colour composited against the
7
+ * effective background, which is itself composited through transparent
8
+ * ancestors down to the page base. Collapsed disclosures are force-opened first
9
+ * so hidden text is measured too.
10
+ *
11
+ * Exits non-zero if navigation, copy, filtering, progress persistence, or
12
+ * contrast fails.
13
+ *
14
+ * node scripts/site-contrast.mjs
15
+ * node scripts/site-contrast.mjs --threshold 7 # AAA
16
+ * node scripts/site-contrast.mjs --verbose # list every failure
17
+ *
18
+ * Chrome is found via CHROME_PATH, or the usual macOS/Linux locations.
19
+ */
20
+ import { spawn } from 'node:child_process';
21
+ import fs from 'node:fs';
22
+ import http from 'node:http';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ import WebSocket from 'ws';
28
+
29
+ const runnerDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const siteDir = path.join(runnerDir, 'site');
31
+
32
+ const args = process.argv.slice(2);
33
+ const threshold = Number(readFlag('--threshold') ?? 4.5);
34
+ const verbose = args.includes('--verbose');
35
+
36
+ const MIME = {
37
+ '.html': 'text/html; charset=utf-8',
38
+ '.css': 'text/css; charset=utf-8',
39
+ '.mjs': 'text/javascript; charset=utf-8',
40
+ '.js': 'text/javascript; charset=utf-8',
41
+ '.json': 'application/json; charset=utf-8',
42
+ '.png': 'image/png',
43
+ '.svg': 'image/svg+xml',
44
+ '.woff2': 'font/woff2',
45
+ };
46
+
47
+ /*
48
+ * Runs inside the page. Returns one record per element that renders its own
49
+ * text, with the ratio already composited.
50
+ */
51
+ const AUDIT = String.raw`
52
+ (() => {
53
+ function parse(c) {
54
+ const m = c.match(/rgba?\(([^)]+)\)/);
55
+ if (!m) return null;
56
+ const p = m[1].split(',').map((s) => parseFloat(s.trim()));
57
+ return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };
58
+ }
59
+ function over(fg, bg) {
60
+ const a = fg.a;
61
+ return {
62
+ r: fg.r * a + bg.r * (1 - a),
63
+ g: fg.g * a + bg.g * (1 - a),
64
+ b: fg.b * a + bg.b * (1 - a),
65
+ a: 1,
66
+ };
67
+ }
68
+ function lum(c) {
69
+ const f = (v) => {
70
+ v /= 255;
71
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
72
+ };
73
+ return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
74
+ }
75
+ function ratio(a, b) {
76
+ const l1 = lum(a);
77
+ const l2 = lum(b);
78
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
79
+ }
80
+ function effectiveBg(el) {
81
+ const stack = [];
82
+ let node = el;
83
+ while (node && node !== document.documentElement) {
84
+ const bg = parse(getComputedStyle(node).backgroundColor);
85
+ if (bg && bg.a > 0) {
86
+ stack.push(bg);
87
+ if (bg.a === 1) break;
88
+ }
89
+ node = node.parentElement;
90
+ }
91
+ const rootBg = parse(getComputedStyle(document.documentElement).backgroundColor);
92
+ let base = rootBg && rootBg.a === 1 ? rootBg : { r: 255, g: 255, b: 255, a: 1 };
93
+ for (let i = stack.length - 1; i >= 0; i--) base = over(stack[i], base);
94
+ return base;
95
+ }
96
+ function ownText(el) {
97
+ for (const n of el.childNodes) {
98
+ if (n.nodeType === 3 && n.textContent.trim().length) return true;
99
+ }
100
+ return false;
101
+ }
102
+ function hex(c) {
103
+ const h = (v) => Math.round(v).toString(16).padStart(2, '0');
104
+ return '#' + h(c.r) + h(c.g) + h(c.b);
105
+ }
106
+
107
+ const out = [];
108
+ document.querySelectorAll('*').forEach((el) => {
109
+ if (!ownText(el)) return;
110
+ const cs = getComputedStyle(el);
111
+ if (cs.visibility === 'hidden' || cs.display === 'none') return;
112
+ if (parseFloat(cs.opacity) === 0) return;
113
+ const rect = el.getBoundingClientRect();
114
+ if (!rect.width || !rect.height) return;
115
+ const raw = parse(cs.color);
116
+ if (!raw) return;
117
+ const bg = effectiveBg(el);
118
+ const fg = raw.a < 1 ? over(raw, bg) : raw;
119
+ let sel = el.tagName.toLowerCase();
120
+ if (typeof el.className === 'string' && el.className.trim()) {
121
+ sel += '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.');
122
+ }
123
+ out.push({
124
+ sel,
125
+ text: el.textContent.trim().slice(0, 48).replace(/\s+/g, ' '),
126
+ fg: hex(fg),
127
+ bg: hex(bg),
128
+ ratio: Math.round(ratio(fg, bg) * 100) / 100,
129
+ size: Math.round(parseFloat(cs.fontSize) * 10) / 10,
130
+ weight: parseInt(cs.fontWeight, 10) || 400,
131
+ });
132
+ });
133
+ return out;
134
+ })()
135
+ `;
136
+
137
+ /* Open everything that hides text behind a disclosure. */
138
+ const REVEAL = String.raw`
139
+ (() => {
140
+ const style = document.createElement('style');
141
+ style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; }';
142
+ document.head.appendChild(style);
143
+ document.querySelectorAll('details').forEach((d) => { d.open = true; });
144
+ document.querySelectorAll('.layer[aria-controls]').forEach((l) => {
145
+ l.setAttribute('aria-expanded', 'true');
146
+ const d = document.getElementById(l.getAttribute('aria-controls'));
147
+ if (d) d.hidden = false;
148
+ });
149
+ return true;
150
+ })()
151
+ `;
152
+
153
+ main().catch((err) => {
154
+ console.error(`site-contrast: ${err.message}`);
155
+ process.exit(1);
156
+ });
157
+
158
+ async function main() {
159
+ if (!fs.existsSync(siteDir)) {
160
+ throw new Error(`no site directory at ${siteDir}`);
161
+ }
162
+ const pages = listPages(siteDir);
163
+ if (pages.length === 0) throw new Error('no .html pages found under site/');
164
+
165
+ const server = await startServer(siteDir);
166
+ const chrome = await startChrome();
167
+ let failures = 0;
168
+ const rows = [];
169
+
170
+ try {
171
+ for (const page of pages) {
172
+ const url = `http://127.0.0.1:${server.port}/${page}`;
173
+ const measured = await auditPage(chrome.endpoint, url);
174
+ const under = measured.filter((m) => m.ratio < threshold);
175
+ const worst = measured.reduce((a, b) => (b.ratio < a.ratio ? b : a), measured[0]);
176
+ failures += under.length;
177
+ rows.push({ page, count: measured.length, under: under.length, worst });
178
+ if (verbose && under.length) {
179
+ for (const u of under) {
180
+ console.error(
181
+ ` ${u.ratio}:1 ${u.fg} on ${u.bg} ${u.size}px/${u.weight} ${u.sel} "${u.text}"`,
182
+ );
183
+ }
184
+ }
185
+ }
186
+ await checkBehavior(chrome.endpoint, server.port, pages);
187
+ } finally {
188
+ await chrome.close();
189
+ await server.close();
190
+ }
191
+
192
+ report(rows, failures);
193
+ console.log('PASS — browser copy, filters, progress, and internal links');
194
+ process.exit(failures === 0 ? 0 : 1);
195
+ }
196
+
197
+ function report(rows, failures) {
198
+ const w = Math.max(...rows.map((r) => r.page.length), 4);
199
+ console.log(`WCAG contrast — threshold ${threshold}:1 — ${rows.length} pages\n`);
200
+ console.log(` ${'page'.padEnd(w)} elements worst element`);
201
+ console.log(` ${'-'.repeat(w)} -------- ------- -------`);
202
+ for (const r of rows) {
203
+ const mark = r.under === 0 ? ' ' : '!';
204
+ console.log(
205
+ `${mark} ${r.page.padEnd(w)} ${String(r.count).padStart(8)} ${String(r.worst.ratio).padStart(6)}:1 ${r.worst.sel}`,
206
+ );
207
+ }
208
+ const total = rows.reduce((n, r) => n + r.count, 0);
209
+ console.log('');
210
+ if (failures === 0) {
211
+ console.log(`PASS — 0 of ${total} text elements below ${threshold}:1`);
212
+ } else {
213
+ console.log(`FAIL — ${failures} of ${total} text elements below ${threshold}:1`);
214
+ if (!verbose) console.log('Re-run with --verbose to list them.');
215
+ }
216
+ }
217
+
218
+ function listPages(root) {
219
+ const out = [];
220
+ (function walk(dir) {
221
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
222
+ const full = path.join(dir, entry.name);
223
+ if (entry.isDirectory()) walk(full);
224
+ else if (entry.isFile() && entry.name.endsWith('.html')) {
225
+ out.push(path.relative(root, full).split(path.sep).join('/'));
226
+ }
227
+ }
228
+ })(root);
229
+ return out.sort();
230
+ }
231
+
232
+ function startServer(root) {
233
+ return new Promise((resolve, reject) => {
234
+ const server = http.createServer((req, res) => {
235
+ const rel = decodeURIComponent((req.url || '/').split('?')[0]).replace(/^\/+/, '');
236
+ const file = path.join(root, rel);
237
+ if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
238
+ res.writeHead(404).end('not found');
239
+ return;
240
+ }
241
+ res.writeHead(200, {
242
+ 'Content-Type': MIME[path.extname(file)] ?? 'application/octet-stream',
243
+ 'Cache-Control': 'no-store',
244
+ });
245
+ fs.createReadStream(file).pipe(res);
246
+ });
247
+ server.on('error', reject);
248
+ server.listen(0, '127.0.0.1', () => {
249
+ resolve({
250
+ port: server.address().port,
251
+ close: () => new Promise((done) => server.close(done)),
252
+ });
253
+ });
254
+ });
255
+ }
256
+
257
+ function findChrome() {
258
+ if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
259
+ const candidates = [
260
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
261
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
262
+ '/usr/bin/google-chrome',
263
+ '/usr/bin/chromium',
264
+ '/usr/bin/chromium-browser',
265
+ ];
266
+ const found = candidates.find((c) => fs.existsSync(c));
267
+ if (!found) {
268
+ throw new Error('Chrome not found. Set CHROME_PATH to a Chrome or Chromium binary.');
269
+ }
270
+ return found;
271
+ }
272
+
273
+ async function startChrome() {
274
+ const binary = findChrome();
275
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'site-contrast-'));
276
+ const child = spawn(
277
+ binary,
278
+ [
279
+ '--headless=new',
280
+ '--disable-gpu',
281
+ '--no-first-run',
282
+ '--no-default-browser-check',
283
+ '--remote-debugging-port=0',
284
+ `--user-data-dir=${userDataDir}`,
285
+ 'about:blank',
286
+ ],
287
+ { stdio: 'ignore' },
288
+ );
289
+
290
+ // Chrome writes the chosen port to DevToolsActivePort once it is listening.
291
+ const portFile = path.join(userDataDir, 'DevToolsActivePort');
292
+ const port = await waitFor(() => {
293
+ if (!fs.existsSync(portFile)) return null;
294
+ const line = fs.readFileSync(portFile, 'utf8').split('\n')[0].trim();
295
+ return line ? Number(line) : null;
296
+ }, 15000, 'Chrome did not report a debugging port');
297
+
298
+ const version = await getJson(`http://127.0.0.1:${port}/json/version`);
299
+ return {
300
+ endpoint: version.webSocketDebuggerUrl,
301
+ port,
302
+ close: async () => {
303
+ child.kill();
304
+ // Chrome holds the profile dir open until it exits; removing it early
305
+ // fails with ENOTEMPTY. Wait for exit, and never fail the gate on cleanup.
306
+ await new Promise((done) => {
307
+ if (child.exitCode !== null || child.signalCode !== null) return done();
308
+ const timer = setTimeout(done, 5000);
309
+ child.once('exit', () => {
310
+ clearTimeout(timer);
311
+ done();
312
+ });
313
+ });
314
+ try {
315
+ fs.rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
316
+ } catch {
317
+ /* a leftover temp profile is not worth failing a contrast run over */
318
+ }
319
+ },
320
+ };
321
+ }
322
+
323
+ async function auditPage(browserEndpoint, url) {
324
+ return withPage(browserEndpoint, url, async (page) => {
325
+ await page.evaluate(REVEAL);
326
+ await new Promise((r) => setTimeout(r, 150));
327
+ return page.evaluate(AUDIT);
328
+ });
329
+ }
330
+
331
+ async function checkBehavior(browserEndpoint, port, pages) {
332
+ const base = `http://127.0.0.1:${port}/`;
333
+ await withPage(browserEndpoint, `${base}index.html`, async (page) => {
334
+ const copied = await page.evaluate(String.raw`
335
+ (async () => {
336
+ Object.defineProperty(navigator, 'clipboard', {
337
+ configurable: true,
338
+ value: {
339
+ writeText(text) {
340
+ window.__siteCopied = text;
341
+ return Promise.resolve();
342
+ },
343
+ },
344
+ });
345
+ const button = document.querySelector('.prompt .copy');
346
+ if (!button) throw new Error('copy button was not initialized');
347
+ button.click();
348
+ await new Promise((resolve) => setTimeout(resolve, 25));
349
+ return window.__siteCopied;
350
+ })()
351
+ `);
352
+ const expected = 'mm-harness run <recipe> --artifacts-dir ./first-recipe-artifacts';
353
+ if (!copied?.includes(expected)) {
354
+ throw new Error(`copy prompt omitted "${expected}"`);
355
+ }
356
+
357
+ const progress = await page.evaluate(String.raw`
358
+ (() => {
359
+ const box = document.querySelector('.step-check');
360
+ box.click();
361
+ return {
362
+ stored: localStorage.getItem('mmh.progress.start'),
363
+ label: document.querySelector('.progress-label')?.textContent,
364
+ };
365
+ })()
366
+ `);
367
+ if (!progress.stored || progress.stored === '[]' || !/^1\//.test(progress.label ?? '')) {
368
+ throw new Error('checklist progress was not persisted and rendered');
369
+ }
370
+ });
371
+
372
+ await withPage(browserEndpoint, `${base}cheatsheet.html`, async (page) => {
373
+ const coreCommands = await page.evaluate(String.raw`
374
+ (() => {
375
+ document.querySelector('.chip[data-platform="core"]').click();
376
+ return [...document.querySelectorAll('tr[data-platforms]:not([hidden]) code')]
377
+ .map((node) => node.textContent.trim());
378
+ })()
379
+ `);
380
+ const forbidden = coreCommands.filter((command) =>
381
+ /mm-harness (logs|stop)|mm-harness call (read_state|navigate)|mm-harness actions --category ui/.test(command),
382
+ );
383
+ if (forbidden.length) {
384
+ throw new Error(`Core filter exposed unsupported commands: ${forbidden.join(', ')}`);
385
+ }
386
+ if (!coreCommands.some((command) => command.includes('call read_positions'))) {
387
+ throw new Error('Core filter omitted its headless read action');
388
+ }
389
+ });
390
+
391
+ for (const relative of pages) {
392
+ await withPage(browserEndpoint, `${base}${relative}`, async (page) => {
393
+ const broken = await page.evaluate(String.raw`
394
+ (async () => {
395
+ const links = [...document.querySelectorAll('a[href]')]
396
+ .map((link) => new URL(link.getAttribute('href'), location.href))
397
+ .filter((url) => url.origin === location.origin);
398
+ const failures = [];
399
+ for (const url of links) {
400
+ let response;
401
+ for (let attempt = 0; attempt < 2 && !response; attempt += 1) {
402
+ try {
403
+ response = await fetch(url.pathname);
404
+ } catch {
405
+ await new Promise((resolve) => setTimeout(resolve, 100));
406
+ }
407
+ }
408
+ if (!response?.ok) failures.push(url.pathname + ' (' + (response?.status ?? 'network') + ')');
409
+ }
410
+ return failures;
411
+ })()
412
+ `);
413
+ if (broken.length) {
414
+ throw new Error(`${relative} has broken internal links: ${broken.join(', ')}`);
415
+ }
416
+ });
417
+ }
418
+ }
419
+
420
+ async function withPage(browserEndpoint, url, inspect) {
421
+ // A fresh target per page keeps one page's state out of the next one's.
422
+ const browser = await connect(browserEndpoint);
423
+ let targetId;
424
+ try {
425
+ ({ targetId } = await browser.send('Target.createTarget', { url: 'about:blank' }));
426
+ const { sessionId } = await browser.send('Target.attachToTarget', {
427
+ targetId,
428
+ flatten: true,
429
+ });
430
+ const page = browser.session(sessionId);
431
+ await page.send('Page.enable');
432
+ await page.send('Runtime.enable');
433
+ await page.send('Emulation.setDeviceMetricsOverride', {
434
+ width: 1440,
435
+ height: 1000,
436
+ deviceScaleFactor: 1,
437
+ mobile: false,
438
+ });
439
+ await page.send('Page.navigate', { url });
440
+ await waitFor(async () => {
441
+ const r = await page.evaluate('document.readyState');
442
+ return r === 'complete' ? true : null;
443
+ }, 15000, `page never finished loading: ${url}`);
444
+ // The stylesheet and module both land before readyState complete, but give
445
+ // the module a beat to run so injected controls are present.
446
+ await new Promise((r) => setTimeout(r, 250));
447
+ return await inspect(page);
448
+ } finally {
449
+ if (targetId) await browser.send('Target.closeTarget', { targetId }).catch(() => {});
450
+ browser.close();
451
+ }
452
+ }
453
+
454
+ function connect(endpoint) {
455
+ return new Promise((resolve, reject) => {
456
+ const ws = new WebSocket(endpoint, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 });
457
+ const pending = new Map();
458
+ let id = 0;
459
+
460
+ ws.on('message', (raw) => {
461
+ const msg = JSON.parse(raw.toString());
462
+ if (!msg.id || !pending.has(msg.id)) return;
463
+ const { ok, fail } = pending.get(msg.id);
464
+ pending.delete(msg.id);
465
+ if (msg.error) fail(new Error(msg.error.message ?? JSON.stringify(msg.error)));
466
+ else ok(msg.result);
467
+ });
468
+ ws.on('error', reject);
469
+
470
+ const send = (method, params = {}, sessionId) =>
471
+ new Promise((ok, fail) => {
472
+ const mid = ++id;
473
+ pending.set(mid, { ok, fail });
474
+ ws.send(JSON.stringify({ id: mid, method, params, sessionId }));
475
+ setTimeout(() => {
476
+ if (pending.delete(mid)) fail(new Error(`timeout: ${method}`));
477
+ }, 30000);
478
+ });
479
+
480
+ const evaluate = async (expression, sessionId) => {
481
+ const r = await send(
482
+ 'Runtime.evaluate',
483
+ { expression, returnByValue: true, awaitPromise: true },
484
+ sessionId,
485
+ );
486
+ if (r.exceptionDetails) {
487
+ throw new Error(r.exceptionDetails.exception?.description ?? 'evaluate failed');
488
+ }
489
+ return r.result.value;
490
+ };
491
+
492
+ ws.on('open', () =>
493
+ resolve({
494
+ send,
495
+ close: () => ws.close(),
496
+ session: (sessionId) => ({
497
+ send: (m, p) => send(m, p, sessionId),
498
+ evaluate: (e) => evaluate(e, sessionId),
499
+ }),
500
+ }),
501
+ );
502
+ });
503
+ }
504
+
505
+ async function waitFor(probe, timeoutMs, message) {
506
+ const deadline = Date.now() + timeoutMs;
507
+ while (Date.now() < deadline) {
508
+ const value = await probe();
509
+ if (value !== null && value !== undefined && value !== false) return value;
510
+ await new Promise((r) => setTimeout(r, 100));
511
+ }
512
+ throw new Error(message);
513
+ }
514
+
515
+ function getJson(url) {
516
+ return new Promise((resolve, reject) => {
517
+ http
518
+ .get(url, (res) => {
519
+ let body = '';
520
+ res.on('data', (c) => {
521
+ body += c;
522
+ });
523
+ res.on('end', () => {
524
+ try {
525
+ resolve(JSON.parse(body));
526
+ } catch (err) {
527
+ reject(err);
528
+ }
529
+ });
530
+ })
531
+ .on('error', reject);
532
+ });
533
+ }
534
+
535
+ function readFlag(name) {
536
+ const at = args.indexOf(name);
537
+ return at === -1 ? undefined : args[at + 1];
538
+ }