ymmv-cli 0.5.0 → 0.6.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/README.md +7 -2
- package/dist/cli.js +578 -330
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,13 +16,15 @@ npx ymmv-cli bardisty # view someone's stack in the terminal
|
|
|
16
16
|
Viewing someone while you're logged in diffs their stack against yours:
|
|
17
17
|
|
|
18
18
|
```
|
|
19
|
-
|
|
19
|
+
how bardisty differs from you
|
|
20
|
+
|
|
21
|
+
BARDISTY YOU
|
|
20
22
|
~ Editor Zed VS Code
|
|
21
23
|
= Shell bash bash
|
|
22
24
|
~ Theme Gruvbox Catppuccin
|
|
23
25
|
~ Font Lilex JetBrains Mono
|
|
24
26
|
|
|
25
|
-
3 differ
|
|
27
|
+
3 differ 1 shared
|
|
26
28
|
```
|
|
27
29
|
|
|
28
30
|
Install once for the short `ymmv` command:
|
|
@@ -48,6 +50,9 @@ via npm Trusted Publishing, with provenance.
|
|
|
48
50
|
|
|
49
51
|
Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`.
|
|
50
52
|
|
|
53
|
+
Color output respects `NO_COLOR`; set `YMMV_API` to point the CLI at a different Worker
|
|
54
|
+
(development).
|
|
55
|
+
|
|
51
56
|
## License
|
|
52
57
|
|
|
53
58
|
MIT. Source + issues: <https://github.com/ymmv-fyi/ymmv>.
|
package/dist/cli.js
CHANGED
|
@@ -6,47 +6,6 @@ import { readFileSync as readFileSync2 } from "fs";
|
|
|
6
6
|
// src/config.ts
|
|
7
7
|
var BASE = (process.env.YMMV_API ?? "https://ymmv.fyi").replace(/\/+$/, "");
|
|
8
8
|
|
|
9
|
-
// src/auth-http.ts
|
|
10
|
-
async function mintYmmvToken(accessToken) {
|
|
11
|
-
const res = await fetch(`${BASE}/api/v1/auth/token`, {
|
|
12
|
-
method: "POST",
|
|
13
|
-
headers: { "content-type": "application/json" },
|
|
14
|
-
body: JSON.stringify({ access_token: accessToken }),
|
|
15
|
-
// Never follow a redirect: a 30x must fail (the existing `!res.ok` guard rejects the resulting
|
|
16
|
-
// opaqueredirect), not re-POST the GitHub access_token to the redirect target or read a
|
|
17
|
-
// redirected 200 as a successful mint. Mirrors publish/delete in api.ts.
|
|
18
|
-
redirect: "manual"
|
|
19
|
-
});
|
|
20
|
-
if (!res.ok) {
|
|
21
|
-
const body = await res.json().catch(() => ({}));
|
|
22
|
-
if (res.status === 503) {
|
|
23
|
-
throw new Error(body.message ?? "GitHub is unavailable \u2014 run `ymmv login` again shortly.");
|
|
24
|
-
}
|
|
25
|
-
if (res.status === 429) {
|
|
26
|
-
const retry = res.headers.get("retry-after");
|
|
27
|
-
const msg = body.message ?? "too many login attempts \u2014 slow down and try again shortly";
|
|
28
|
-
throw new Error(retry ? `${msg} (retry in ${retry}s)` : msg);
|
|
29
|
-
}
|
|
30
|
-
throw new Error(`login failed: ${res.status} ${body.error ?? ""}`.trim());
|
|
31
|
-
}
|
|
32
|
-
return await res.json();
|
|
33
|
-
}
|
|
34
|
-
async function revokeYmmvToken(token) {
|
|
35
|
-
const res = await fetch(`${BASE}/api/v1/auth/logout`, {
|
|
36
|
-
method: "POST",
|
|
37
|
-
headers: { authorization: `Bearer ${token}` },
|
|
38
|
-
// Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete the
|
|
39
|
-
// local file while the server token stays live). Same guard as mint + publish/delete.
|
|
40
|
-
redirect: "manual"
|
|
41
|
-
});
|
|
42
|
-
if (!res.ok) throw new Error(`logout failed: ${res.status}`);
|
|
43
|
-
const body = await res.json().catch(() => ({}));
|
|
44
|
-
return body.revoked === true;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// src/commands.ts
|
|
48
|
-
import { readFileSync, statSync } from "fs";
|
|
49
|
-
|
|
50
9
|
// ../shared/dist/keys.js
|
|
51
10
|
var CURATED_KEYS = [
|
|
52
11
|
"editor",
|
|
@@ -311,8 +270,8 @@ var MAX_PARSE_EXTRAS = 256;
|
|
|
311
270
|
var MAX_PARSE_VALUE = 4096;
|
|
312
271
|
var MAX_PARSE_LABEL = 4096;
|
|
313
272
|
var ProfileParseError = class extends Error {
|
|
314
|
-
constructor(
|
|
315
|
-
super(
|
|
273
|
+
constructor(message2) {
|
|
274
|
+
super(message2);
|
|
316
275
|
this.name = "ProfileParseError";
|
|
317
276
|
}
|
|
318
277
|
};
|
|
@@ -380,6 +339,270 @@ function isValidHandle(handle) {
|
|
|
380
339
|
return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
|
|
381
340
|
}
|
|
382
341
|
|
|
342
|
+
// src/render.ts
|
|
343
|
+
var ESC = String.fromCharCode(27);
|
|
344
|
+
var CSI = `${ESC}[`;
|
|
345
|
+
var CODES = {
|
|
346
|
+
amber: `${CSI}93m`,
|
|
347
|
+
// DESIGN: amber == ANSI bright-yellow
|
|
348
|
+
faint: `${CSI}90m`,
|
|
349
|
+
bold: `${CSI}1m`,
|
|
350
|
+
reset: `${CSI}0m`
|
|
351
|
+
};
|
|
352
|
+
var NO_CODES = { amber: "", faint: "", bold: "", reset: "" };
|
|
353
|
+
function palette(color) {
|
|
354
|
+
return color ? CODES : NO_CODES;
|
|
355
|
+
}
|
|
356
|
+
function message(text) {
|
|
357
|
+
return `
|
|
358
|
+
${text.split(/\r?\n/).map((l) => l ? ` ${l}` : l).join("\n")}`;
|
|
359
|
+
}
|
|
360
|
+
var ESC_INTRODUCERS = `${String.fromCharCode(27)}${String.fromCharCode(155)}`;
|
|
361
|
+
var BEL = String.fromCharCode(7);
|
|
362
|
+
var ANSI_RE = new RegExp(
|
|
363
|
+
`[${ESC_INTRODUCERS}][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?${BEL})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`,
|
|
364
|
+
"g"
|
|
365
|
+
);
|
|
366
|
+
var CTRL_RE = new RegExp(
|
|
367
|
+
`[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
|
|
368
|
+
"g"
|
|
369
|
+
);
|
|
370
|
+
var BIDI_RE = new RegExp(
|
|
371
|
+
`[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
|
|
372
|
+
"g"
|
|
373
|
+
);
|
|
374
|
+
function sanitizeValue(value) {
|
|
375
|
+
return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
|
|
376
|
+
}
|
|
377
|
+
function useColor(env, isTTY) {
|
|
378
|
+
if (env.NO_COLOR !== void 0) return false;
|
|
379
|
+
if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
|
|
380
|
+
if (env.TERM === "dumb") return false;
|
|
381
|
+
return isTTY;
|
|
382
|
+
}
|
|
383
|
+
function colorEnabled() {
|
|
384
|
+
return useColor(process.env, Boolean(process.stdout.isTTY));
|
|
385
|
+
}
|
|
386
|
+
var OSC = `${ESC}]`;
|
|
387
|
+
var ST = `${ESC}\\`;
|
|
388
|
+
function displayUrl(value) {
|
|
389
|
+
return value.trim().replace(/^https:\/\/(?=.)/i, "");
|
|
390
|
+
}
|
|
391
|
+
var HTTP_URL_RE = /^https?:\/\/\S+$/i;
|
|
392
|
+
function isHttpUrl(value) {
|
|
393
|
+
return HTTP_URL_RE.test(value.trim());
|
|
394
|
+
}
|
|
395
|
+
var NO_OSC8_TERMS = /* @__PURE__ */ new Set(["linux", "dumb"]);
|
|
396
|
+
function link(url, color, term = process.env.TERM) {
|
|
397
|
+
const clean = sanitizeValue(url).trim();
|
|
398
|
+
if (!color) return clean;
|
|
399
|
+
const text = `${CODES.amber}${displayUrl(clean)}${CODES.reset}`;
|
|
400
|
+
if (term !== void 0 && NO_OSC8_TERMS.has(term)) return text;
|
|
401
|
+
return `${OSC}8;;${clean}${ST}${text}${OSC}8;;${ST}`;
|
|
402
|
+
}
|
|
403
|
+
function relTime(iso, now = Date.now) {
|
|
404
|
+
const t = Date.parse(iso);
|
|
405
|
+
if (Number.isNaN(t)) return sanitizeValue(iso);
|
|
406
|
+
const ms = now() - t;
|
|
407
|
+
if (ms < 6e4) return "just now";
|
|
408
|
+
const m = Math.floor(ms / 6e4);
|
|
409
|
+
if (m < 60) return `${m}m ago`;
|
|
410
|
+
const h = Math.floor(m / 60);
|
|
411
|
+
if (h < 24) return `${h}h ago`;
|
|
412
|
+
const d = Math.floor(h / 24);
|
|
413
|
+
if (d < 30) return `${d}d ago`;
|
|
414
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
415
|
+
}
|
|
416
|
+
function renderProfile(profile, opts) {
|
|
417
|
+
const c = palette(opts.color);
|
|
418
|
+
const preview = opts.mode === "preview";
|
|
419
|
+
const byKey = new Map(
|
|
420
|
+
(profile.entries ?? []).map((e) => [e.key, e.value])
|
|
421
|
+
);
|
|
422
|
+
const rows = CURATED_KEYS.flatMap((key) => {
|
|
423
|
+
const value = byKey.get(key);
|
|
424
|
+
if (value === void 0 && !preview) return [];
|
|
425
|
+
return [{ label: KEY_LABELS[key], value: value === void 0 ? null : sanitizeValue(value) }];
|
|
426
|
+
});
|
|
427
|
+
const extras = (profile.extras ?? []).map((x) => ({
|
|
428
|
+
label: sanitizeValue(x.label),
|
|
429
|
+
value: sanitizeValue(x.value)
|
|
430
|
+
}));
|
|
431
|
+
const labelW = Math.max(
|
|
432
|
+
0,
|
|
433
|
+
...rows.map((r) => r.label.length),
|
|
434
|
+
...extras.map((x) => x.label.length)
|
|
435
|
+
);
|
|
436
|
+
const val = (v) => isHttpUrl(v) ? link(v, opts.color) : v;
|
|
437
|
+
const lines = [
|
|
438
|
+
"",
|
|
439
|
+
` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`,
|
|
440
|
+
""
|
|
441
|
+
];
|
|
442
|
+
for (const r of rows) {
|
|
443
|
+
lines.push(
|
|
444
|
+
r.value === null ? ` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}` : ` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
if (extras.length) {
|
|
448
|
+
lines.push("");
|
|
449
|
+
for (const x of extras) {
|
|
450
|
+
lines.push(` ${c.faint}${x.label.padEnd(labelW)}${c.reset} ${val(x.value)}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (!preview) {
|
|
454
|
+
lines.push("", ` ${c.faint}updated ${relTime(profile.updated_at, opts.now)}${c.reset}`);
|
|
455
|
+
}
|
|
456
|
+
return lines.join("\n");
|
|
457
|
+
}
|
|
458
|
+
var MISSING = "\u2014";
|
|
459
|
+
function extrasBlock(extras, theirsLabel, mineLabel, c) {
|
|
460
|
+
if (!extras.theirs.length && !extras.mine.length) return [];
|
|
461
|
+
const out = ["", ` ${c.faint}extras${c.reset}`];
|
|
462
|
+
const line = (who, label, value) => ` ${c.faint}${who}${c.reset} ${sanitizeValue(label)} = ${sanitizeValue(value)}`;
|
|
463
|
+
for (const x of extras.theirs) out.push(line(theirsLabel, x.label, x.value));
|
|
464
|
+
for (const x of extras.mine) out.push(line(mineLabel, x.label, x.value));
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
function renderDiff(result, opts) {
|
|
468
|
+
const c = palette(opts.color);
|
|
469
|
+
const theirsLabel = sanitizeValue(opts.theirsLabel);
|
|
470
|
+
const mineLabel = sanitizeValue(opts.mineLabel);
|
|
471
|
+
const cells = result.rows.map((r) => ({
|
|
472
|
+
label: r.label,
|
|
473
|
+
theirs: r.theirs === null ? MISSING : sanitizeValue(r.theirs),
|
|
474
|
+
mine: r.mine === null ? MISSING : sanitizeValue(r.mine),
|
|
475
|
+
differ: r.status !== "same"
|
|
476
|
+
}));
|
|
477
|
+
const labelW = Math.max(3, ...cells.map((r) => r.label.length));
|
|
478
|
+
const theirsHead = theirsLabel.toUpperCase();
|
|
479
|
+
const theirsW = Math.max(theirsHead.length, ...cells.map((r) => r.theirs.length));
|
|
480
|
+
const lines = [
|
|
481
|
+
"",
|
|
482
|
+
// The web diff's h1, collapsed to one line — information (which side is which), not decoration,
|
|
483
|
+
// so it prints in both color modes.
|
|
484
|
+
` ${c.faint}how${c.reset} ${c.bold}${theirsLabel}${c.reset} ${c.faint}differs from${c.reset} ${c.bold}${mineLabel}${c.reset}`,
|
|
485
|
+
""
|
|
486
|
+
];
|
|
487
|
+
lines.push(
|
|
488
|
+
` ${c.faint}${"".padEnd(labelW)} ${theirsHead.padEnd(theirsW)} ${mineLabel.toUpperCase()}${c.reset}`
|
|
489
|
+
);
|
|
490
|
+
for (const r of cells) {
|
|
491
|
+
if (!opts.color) {
|
|
492
|
+
const sym = r.differ ? "~" : "=";
|
|
493
|
+
lines.push(`${sym} ${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}`);
|
|
494
|
+
} else if (r.differ) {
|
|
495
|
+
lines.push(
|
|
496
|
+
`${c.amber}\u2022${c.reset} ${r.label.padEnd(labelW)} ${c.amber}${r.theirs.padEnd(theirsW)}${c.reset} ${c.amber}${r.mine}${c.reset}`
|
|
497
|
+
);
|
|
498
|
+
} else {
|
|
499
|
+
lines.push(
|
|
500
|
+
` ${c.faint}${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}${c.reset}`
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
lines.push(...extrasBlock(result.extras, theirsLabel, mineLabel, c));
|
|
505
|
+
lines.push("", ` ${c.faint}${result.differ} differ ${result.shared} shared${c.reset}`);
|
|
506
|
+
return lines.join("\n");
|
|
507
|
+
}
|
|
508
|
+
function nudge(color) {
|
|
509
|
+
const c = palette(color);
|
|
510
|
+
return `
|
|
511
|
+
${c.amber}publish yours to diff \u2192${c.reset} run ${c.bold}ymmv${c.reset}`;
|
|
512
|
+
}
|
|
513
|
+
function notFound(handle, color, base) {
|
|
514
|
+
return `
|
|
515
|
+
no ymmv profile for "${sanitizeValue(handle)}" yet.
|
|
516
|
+
publish one at ${link(base, color)} with: npx ymmv-cli`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/http.ts
|
|
520
|
+
function causeText(err) {
|
|
521
|
+
const pick = (e) => {
|
|
522
|
+
if (e instanceof AggregateError) {
|
|
523
|
+
const first = e.errors.find((x) => x instanceof Error && x.message.length > 0);
|
|
524
|
+
if (first) return first.message;
|
|
525
|
+
const code = e.code;
|
|
526
|
+
if (code) return String(code);
|
|
527
|
+
}
|
|
528
|
+
return e.message;
|
|
529
|
+
};
|
|
530
|
+
let text;
|
|
531
|
+
if (err instanceof Error) {
|
|
532
|
+
const fromCause = err.cause instanceof Error ? pick(err.cause) : "";
|
|
533
|
+
text = fromCause || pick(err) || String(err);
|
|
534
|
+
} else {
|
|
535
|
+
text = String(err);
|
|
536
|
+
}
|
|
537
|
+
return sanitizeValue(text);
|
|
538
|
+
}
|
|
539
|
+
function wireText(text) {
|
|
540
|
+
const clean = sanitizeValue(String(text));
|
|
541
|
+
return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
|
|
542
|
+
}
|
|
543
|
+
async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
544
|
+
try {
|
|
545
|
+
return await fetchFn(url, init);
|
|
546
|
+
} catch (err) {
|
|
547
|
+
throw new Error(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
|
|
548
|
+
cause: err
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/auth-http.ts
|
|
554
|
+
async function mintYmmvToken(accessToken) {
|
|
555
|
+
const res = await safeFetch(
|
|
556
|
+
`${BASE}/api/v1/auth/token`,
|
|
557
|
+
{
|
|
558
|
+
method: "POST",
|
|
559
|
+
headers: { "content-type": "application/json" },
|
|
560
|
+
body: JSON.stringify({ access_token: accessToken }),
|
|
561
|
+
// Never follow a redirect: a 30x must fail (the existing `!res.ok` guard rejects the resulting
|
|
562
|
+
// opaqueredirect), not re-POST the GitHub access_token to the redirect target or read a
|
|
563
|
+
// redirected 200 as a successful mint. Mirrors publish/delete in api.ts.
|
|
564
|
+
redirect: "manual"
|
|
565
|
+
},
|
|
566
|
+
BASE
|
|
567
|
+
);
|
|
568
|
+
if (!res.ok) {
|
|
569
|
+
const body = await res.json().catch(() => ({}));
|
|
570
|
+
if (res.status === 503) {
|
|
571
|
+
throw new Error(
|
|
572
|
+
body.message ? wireText(body.message) : "GitHub is unavailable. Run `ymmv login` again shortly."
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
if (res.status === 429) {
|
|
576
|
+
const retry = res.headers.get("retry-after");
|
|
577
|
+
const msg = body.message ? wireText(body.message) : "Too many login attempts. Slow down and try again shortly";
|
|
578
|
+
throw new Error(retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg);
|
|
579
|
+
}
|
|
580
|
+
throw new Error(`login failed: ${res.status} ${wireText(body.error ?? "")}`.trim());
|
|
581
|
+
}
|
|
582
|
+
const data = await res.json().catch(() => null);
|
|
583
|
+
if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
|
|
584
|
+
throw new Error(
|
|
585
|
+
`Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
|
|
589
|
+
}
|
|
590
|
+
async function revokeYmmvToken(token) {
|
|
591
|
+
const res = await fetch(`${BASE}/api/v1/auth/logout`, {
|
|
592
|
+
method: "POST",
|
|
593
|
+
headers: { authorization: `Bearer ${token}` },
|
|
594
|
+
// Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete the
|
|
595
|
+
// local file while the server token stays live). Same guard as mint + publish/delete.
|
|
596
|
+
redirect: "manual"
|
|
597
|
+
});
|
|
598
|
+
if (!res.ok) throw new Error(`logout failed: ${res.status}`);
|
|
599
|
+
const body = await res.json().catch(() => ({}));
|
|
600
|
+
return body.revoked === true;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// src/commands.ts
|
|
604
|
+
import { readFileSync, statSync } from "fs";
|
|
605
|
+
|
|
383
606
|
// src/token-store.ts
|
|
384
607
|
import { randomUUID } from "crypto";
|
|
385
608
|
import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
@@ -440,15 +663,24 @@ var TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
|
440
663
|
var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
441
664
|
async function requestDeviceCode(deps = {}) {
|
|
442
665
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
443
|
-
const res = await
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
666
|
+
const res = await safeFetch(
|
|
667
|
+
DEVICE_CODE_URL,
|
|
668
|
+
{
|
|
669
|
+
method: "POST",
|
|
670
|
+
headers: { accept: "application/json" },
|
|
671
|
+
body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID })
|
|
672
|
+
},
|
|
673
|
+
"github.com",
|
|
674
|
+
doFetch
|
|
675
|
+
);
|
|
448
676
|
if (!res.ok) {
|
|
449
|
-
throw new Error(`device code request failed: ${res.status} ${await res.text()}`);
|
|
677
|
+
throw new Error(`device code request failed: ${res.status} ${wireText(await res.text())}`);
|
|
678
|
+
}
|
|
679
|
+
const data = await res.json().catch(() => null);
|
|
680
|
+
if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.expires_in !== "number") {
|
|
681
|
+
throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
|
|
450
682
|
}
|
|
451
|
-
return
|
|
683
|
+
return data;
|
|
452
684
|
}
|
|
453
685
|
async function pollForToken(dc, deps = {}) {
|
|
454
686
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
@@ -457,30 +689,39 @@ async function pollForToken(dc, deps = {}) {
|
|
|
457
689
|
let interval = dc.interval || 5;
|
|
458
690
|
const deadline = now() + dc.expires_in * 1e3;
|
|
459
691
|
let transientFailures = 0;
|
|
692
|
+
let lastCause = "";
|
|
460
693
|
const MAX_TRANSIENT_FAILURES = 5;
|
|
461
694
|
while (now() < deadline) {
|
|
462
695
|
await sleep(interval * 1e3);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
696
|
+
let res;
|
|
697
|
+
try {
|
|
698
|
+
res = await doFetch(TOKEN_URL, {
|
|
699
|
+
method: "POST",
|
|
700
|
+
headers: { accept: "application/json" },
|
|
701
|
+
body: new URLSearchParams({
|
|
702
|
+
client_id: GITHUB_CLIENT_ID,
|
|
703
|
+
device_code: dc.device_code,
|
|
704
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
705
|
+
})
|
|
706
|
+
});
|
|
707
|
+
} catch (err) {
|
|
708
|
+
lastCause = causeText(err);
|
|
709
|
+
}
|
|
472
710
|
let tok;
|
|
473
|
-
if (res
|
|
711
|
+
if (res?.ok) {
|
|
474
712
|
try {
|
|
475
713
|
tok = await res.json();
|
|
476
714
|
} catch {
|
|
477
715
|
tok = void 0;
|
|
716
|
+
lastCause = "unexpected response body";
|
|
478
717
|
}
|
|
718
|
+
} else if (res) {
|
|
719
|
+
lastCause = `HTTP ${res.status}`;
|
|
479
720
|
}
|
|
480
721
|
if (tok === void 0) {
|
|
481
722
|
if (++transientFailures >= MAX_TRANSIENT_FAILURES) {
|
|
482
723
|
throw new Error(
|
|
483
|
-
|
|
724
|
+
`GitHub isn't responding to the login poll. Check your connection and run \`ymmv login\` again.${lastCause ? ` (last error: ${lastCause})` : ""}`
|
|
484
725
|
);
|
|
485
726
|
}
|
|
486
727
|
continue;
|
|
@@ -496,23 +737,29 @@ async function pollForToken(dc, deps = {}) {
|
|
|
496
737
|
case "access_denied":
|
|
497
738
|
throw new Error("Authorization denied. Run `ymmv login` to try again.");
|
|
498
739
|
case "expired_token":
|
|
499
|
-
throw new Error("Device code expired
|
|
740
|
+
throw new Error("Device code expired. Run `ymmv login` again.");
|
|
500
741
|
default:
|
|
501
|
-
throw new Error(`device flow failed: ${tok.error ?? "unknown error"}`);
|
|
742
|
+
throw new Error(`device flow failed: ${wireText(tok.error ?? "unknown error")}`);
|
|
502
743
|
}
|
|
503
744
|
}
|
|
504
|
-
throw new Error("Device code expired
|
|
745
|
+
throw new Error("Device code expired. Run `ymmv login` again.");
|
|
505
746
|
}
|
|
506
747
|
async function login(deps = {}) {
|
|
507
748
|
if (!process.stdin.isTTY) {
|
|
508
749
|
throw new Error(
|
|
509
|
-
"Device login needs an interactive terminal
|
|
750
|
+
"Device login needs an interactive terminal. Run `ymmv login` in a real terminal (a piped or CI shell can't complete the GitHub device flow)."
|
|
510
751
|
);
|
|
511
752
|
}
|
|
512
753
|
const dc = await requestDeviceCode(deps);
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
754
|
+
const color = colorEnabled();
|
|
755
|
+
const c = palette(color);
|
|
756
|
+
const verifyUri = /^https:\/\/github\.com\//.test(dc.verification_uri) ? link(dc.verification_uri, color) : sanitizeValue(dc.verification_uri);
|
|
757
|
+
console.log(
|
|
758
|
+
message(
|
|
759
|
+
`Open ${verifyUri} and enter code: ${c.bold}${sanitizeValue(dc.user_code)}${c.reset}
|
|
760
|
+
${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
761
|
+
)
|
|
762
|
+
);
|
|
516
763
|
const accessToken = await pollForToken(dc, deps);
|
|
517
764
|
const { token, handle } = await mintYmmvToken(accessToken);
|
|
518
765
|
try {
|
|
@@ -523,183 +770,65 @@ async function login(deps = {}) {
|
|
|
523
770
|
throw e;
|
|
524
771
|
}
|
|
525
772
|
console.log(
|
|
526
|
-
|
|
527
|
-
` : "
|
|
773
|
+
message(
|
|
774
|
+
handle ? `Logged in as ${handle}.` : "Logged in. No handle bound (your GitHub username is a reserved word)."
|
|
775
|
+
)
|
|
528
776
|
);
|
|
529
777
|
}
|
|
530
778
|
|
|
531
|
-
// src/render.ts
|
|
532
|
-
var ESC = String.fromCharCode(27);
|
|
533
|
-
var CSI = `${ESC}[`;
|
|
534
|
-
var CODES = {
|
|
535
|
-
amber: `${CSI}93m`,
|
|
536
|
-
// DESIGN: amber == ANSI bright-yellow
|
|
537
|
-
faint: `${CSI}90m`,
|
|
538
|
-
bold: `${CSI}1m`,
|
|
539
|
-
reset: `${CSI}0m`
|
|
540
|
-
};
|
|
541
|
-
var NO_CODES = { amber: "", faint: "", bold: "", reset: "" };
|
|
542
|
-
function palette(color) {
|
|
543
|
-
return color ? CODES : NO_CODES;
|
|
544
|
-
}
|
|
545
|
-
var ESC_INTRODUCERS = `${String.fromCharCode(27)}${String.fromCharCode(155)}`;
|
|
546
|
-
var BEL = String.fromCharCode(7);
|
|
547
|
-
var ANSI_RE = new RegExp(
|
|
548
|
-
`[${ESC_INTRODUCERS}][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?${BEL})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`,
|
|
549
|
-
"g"
|
|
550
|
-
);
|
|
551
|
-
var CTRL_RE = new RegExp(
|
|
552
|
-
`[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
|
|
553
|
-
"g"
|
|
554
|
-
);
|
|
555
|
-
var BIDI_RE = new RegExp(
|
|
556
|
-
`[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
|
|
557
|
-
"g"
|
|
558
|
-
);
|
|
559
|
-
function sanitizeValue(value) {
|
|
560
|
-
return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
|
|
561
|
-
}
|
|
562
|
-
function useColor(env, isTTY) {
|
|
563
|
-
if (env.NO_COLOR !== void 0) return false;
|
|
564
|
-
if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
|
|
565
|
-
return isTTY;
|
|
566
|
-
}
|
|
567
|
-
function orderedEntries(profile) {
|
|
568
|
-
const byKey = new Map(
|
|
569
|
-
(profile.entries ?? []).map((e) => [e.key, e.value])
|
|
570
|
-
);
|
|
571
|
-
return CURATED_KEYS.flatMap((key) => {
|
|
572
|
-
const value = byKey.get(key);
|
|
573
|
-
return value === void 0 ? [] : [{ label: KEY_LABELS[key], value: sanitizeValue(value) }];
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
function renderProfile(profile, opts) {
|
|
577
|
-
const c = palette(opts.color);
|
|
578
|
-
const rows = orderedEntries(profile);
|
|
579
|
-
const extras = (profile.extras ?? []).map((x) => ({
|
|
580
|
-
label: sanitizeValue(x.label),
|
|
581
|
-
value: sanitizeValue(x.value)
|
|
582
|
-
}));
|
|
583
|
-
const labelW = Math.max(
|
|
584
|
-
0,
|
|
585
|
-
...rows.map((r) => r.label.length),
|
|
586
|
-
...extras.map((x) => x.label.length)
|
|
587
|
-
);
|
|
588
|
-
const lines = ["", ` ${c.bold}${sanitizeValue(profile.handle)}${c.reset}`, ""];
|
|
589
|
-
for (const r of rows) {
|
|
590
|
-
lines.push(` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${r.value}`);
|
|
591
|
-
}
|
|
592
|
-
if (extras.length) {
|
|
593
|
-
lines.push("");
|
|
594
|
-
for (const x of extras) {
|
|
595
|
-
lines.push(` ${c.faint}${x.label.padEnd(labelW)}${c.reset} ${x.value}`);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
lines.push("", ` ${c.faint}updated ${sanitizeValue(profile.updated_at)}${c.reset}`, "");
|
|
599
|
-
return lines.join("\n");
|
|
600
|
-
}
|
|
601
|
-
var MISSING = "\u2014";
|
|
602
|
-
function extrasBlock(extras, theirsLabel, mineLabel, c) {
|
|
603
|
-
if (!extras.theirs.length && !extras.mine.length) return [];
|
|
604
|
-
const out = ["", ` ${c.faint}extras${c.reset}`];
|
|
605
|
-
const line = (who, label, value) => ` ${c.faint}${who}${c.reset} ${sanitizeValue(label)} = ${sanitizeValue(value)}`;
|
|
606
|
-
for (const x of extras.theirs) out.push(line(theirsLabel, x.label, x.value));
|
|
607
|
-
for (const x of extras.mine) out.push(line(mineLabel, x.label, x.value));
|
|
608
|
-
return out;
|
|
609
|
-
}
|
|
610
|
-
function renderDiff(result, opts) {
|
|
611
|
-
const c = palette(opts.color);
|
|
612
|
-
const theirsLabel = sanitizeValue(opts.theirsLabel);
|
|
613
|
-
const mineLabel = sanitizeValue(opts.mineLabel);
|
|
614
|
-
const cells = result.rows.map((r) => ({
|
|
615
|
-
label: r.label,
|
|
616
|
-
theirs: r.theirs === null ? MISSING : sanitizeValue(r.theirs),
|
|
617
|
-
mine: r.mine === null ? MISSING : sanitizeValue(r.mine),
|
|
618
|
-
differ: r.status !== "same"
|
|
619
|
-
}));
|
|
620
|
-
const labelW = Math.max(3, ...cells.map((r) => r.label.length));
|
|
621
|
-
const theirsW = Math.max(theirsLabel.length, ...cells.map((r) => r.theirs.length));
|
|
622
|
-
const lines = [""];
|
|
623
|
-
lines.push(
|
|
624
|
-
` ${c.faint}${"".padEnd(labelW)} ${theirsLabel.padEnd(theirsW)} ${mineLabel}${c.reset}`
|
|
625
|
-
);
|
|
626
|
-
for (const r of cells) {
|
|
627
|
-
if (!opts.color) {
|
|
628
|
-
const sym = r.differ ? "~" : "=";
|
|
629
|
-
lines.push(`${sym} ${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}`);
|
|
630
|
-
} else if (r.differ) {
|
|
631
|
-
lines.push(
|
|
632
|
-
`${c.amber}\u2022${c.reset} ${r.label.padEnd(labelW)} ${c.amber}${r.theirs.padEnd(theirsW)}${c.reset} ${c.amber}${r.mine}${c.reset}`
|
|
633
|
-
);
|
|
634
|
-
} else {
|
|
635
|
-
lines.push(
|
|
636
|
-
` ${c.faint}${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}${c.reset}`
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
lines.push(...extrasBlock(result.extras, theirsLabel, mineLabel, c));
|
|
641
|
-
lines.push(
|
|
642
|
-
"",
|
|
643
|
-
` ${c.faint}${result.differ} differ \xB7 ${result.shared} shared \u2014 your mileage may vary${c.reset}`,
|
|
644
|
-
""
|
|
645
|
-
);
|
|
646
|
-
return lines.join("\n");
|
|
647
|
-
}
|
|
648
|
-
function nudge(color) {
|
|
649
|
-
const c = palette(color);
|
|
650
|
-
return `
|
|
651
|
-
${c.amber}publish yours to diff \u2192${c.reset} run ${c.bold}ymmv${c.reset}
|
|
652
|
-
`;
|
|
653
|
-
}
|
|
654
|
-
function notFound(handle) {
|
|
655
|
-
return `
|
|
656
|
-
no ymmv profile for "${sanitizeValue(handle)}" yet.
|
|
657
|
-
publish one at ymmv.fyi with: npx ymmv-cli
|
|
658
|
-
`;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
779
|
// src/api.ts
|
|
662
780
|
async function rateLimitMessage(res) {
|
|
663
781
|
const retry = res.headers.get("retry-after");
|
|
664
|
-
let msg = "rate limited
|
|
782
|
+
let msg = "rate limited, too many requests";
|
|
665
783
|
try {
|
|
666
784
|
const body = await res.json();
|
|
667
|
-
if (typeof body?.message === "string" && body.message) msg = body.message;
|
|
785
|
+
if (typeof body?.message === "string" && body.message) msg = wireText(body.message);
|
|
668
786
|
} catch {
|
|
669
787
|
}
|
|
670
|
-
return retry ? `${msg} (retry in ${retry}s)` : msg;
|
|
788
|
+
return retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg;
|
|
671
789
|
}
|
|
672
790
|
async function ensureLogin() {
|
|
673
791
|
const existing = await loadToken();
|
|
674
792
|
if (existing) return existing;
|
|
675
793
|
await login();
|
|
676
794
|
const fresh = await loadToken();
|
|
677
|
-
if (!fresh) throw new Error("
|
|
795
|
+
if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
|
|
678
796
|
return fresh;
|
|
679
797
|
}
|
|
680
798
|
async function publishProfile(profile) {
|
|
681
|
-
const send = (c) =>
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
799
|
+
const send = (c) => safeFetch(
|
|
800
|
+
`${BASE}/api/v1/profile`,
|
|
801
|
+
{
|
|
802
|
+
method: "POST",
|
|
803
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${c.token}` },
|
|
804
|
+
// Send the login-bound handle, never a caller-guessed one — the official client never claims
|
|
805
|
+
// a handle it doesn't own.
|
|
806
|
+
body: JSON.stringify({ ...profile, handle: c.handle ?? profile.handle }),
|
|
807
|
+
redirect: "manual"
|
|
808
|
+
// a mutation must never follow a redirect into a false success
|
|
809
|
+
},
|
|
810
|
+
BASE
|
|
811
|
+
);
|
|
690
812
|
let cred = await ensureLogin();
|
|
691
813
|
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
692
814
|
throw new Error(
|
|
693
|
-
"
|
|
815
|
+
"The stored login changed while this command was running. Re-run it under the current account."
|
|
694
816
|
);
|
|
695
817
|
}
|
|
696
818
|
let res = await send(cred);
|
|
697
819
|
if (res.status === 401 || res.status === 409) {
|
|
698
|
-
|
|
820
|
+
const was401 = res.status === 401;
|
|
821
|
+
if (was401) await deleteToken();
|
|
699
822
|
await login();
|
|
700
823
|
cred = await ensureLogin();
|
|
824
|
+
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
825
|
+
const bound = sanitizeValue(cred.handle ?? "");
|
|
826
|
+
throw new Error(
|
|
827
|
+
was401 ? `The re-login bound a different account ("${bound}", not "${sanitizeValue(profile.handle)}"). Nothing was published. Re-run under the account you meant.` : `Your login now binds "${bound}". Nothing was published. Re-run the command to publish under it.`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
701
830
|
res = await send(cred);
|
|
702
|
-
if (res.status === 401) throw new Error("
|
|
831
|
+
if (res.status === 401) throw new Error("Authentication failed. Run `ymmv login`.");
|
|
703
832
|
if (res.status === 409) {
|
|
704
833
|
throw new Error(
|
|
705
834
|
"that handle is taken by another account (your GitHub handle may have been reused)."
|
|
@@ -708,33 +837,37 @@ async function publishProfile(profile) {
|
|
|
708
837
|
}
|
|
709
838
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
710
839
|
if (!res.ok) {
|
|
711
|
-
throw new Error(`publish failed: ${res.status} ${await res.text()}`);
|
|
840
|
+
throw new Error(`publish failed: ${res.status} ${wireText(await res.text())}`);
|
|
712
841
|
}
|
|
713
842
|
const data = await res.json();
|
|
714
843
|
const shown = typeof data.handle === "string" ? sanitizeValue(data.handle) : profile.handle;
|
|
715
|
-
|
|
844
|
+
return { handle: shown, url: `${BASE}/${shown}` };
|
|
716
845
|
}
|
|
717
846
|
async function fetchProfileJson(handle) {
|
|
718
|
-
const res = await
|
|
847
|
+
const res = await safeFetch(`${BASE}/api/v1/u/${encodeURIComponent(handle)}`, void 0, BASE);
|
|
719
848
|
if (res.status === 404) return null;
|
|
720
849
|
if (!res.ok) {
|
|
721
|
-
throw new Error(`fetch failed: ${res.status} ${await res.text()}`);
|
|
850
|
+
throw new Error(`fetch failed: ${res.status} ${wireText(await res.text())}`);
|
|
722
851
|
}
|
|
723
852
|
return parseProfile(await res.json());
|
|
724
853
|
}
|
|
725
854
|
async function deleteProfile() {
|
|
726
855
|
const cred = await ensureLogin();
|
|
727
|
-
const res = await
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
856
|
+
const res = await safeFetch(
|
|
857
|
+
`${BASE}/api/v1/profile`,
|
|
858
|
+
{
|
|
859
|
+
method: "DELETE",
|
|
860
|
+
headers: { authorization: `Bearer ${cred.token}` },
|
|
861
|
+
redirect: "manual"
|
|
862
|
+
},
|
|
863
|
+
BASE
|
|
864
|
+
);
|
|
732
865
|
if (res.status === 401) {
|
|
733
|
-
throw new Error("
|
|
866
|
+
throw new Error("Session expired. Run `ymmv login`, then `ymmv delete` again.");
|
|
734
867
|
}
|
|
735
868
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
736
869
|
if (!res.ok) {
|
|
737
|
-
throw new Error(`delete failed: ${res.status} ${await res.text()}`);
|
|
870
|
+
throw new Error(`delete failed: ${res.status} ${wireText(await res.text())}`);
|
|
738
871
|
}
|
|
739
872
|
}
|
|
740
873
|
|
|
@@ -1008,14 +1141,103 @@ function applyUnset(existing, target) {
|
|
|
1008
1141
|
};
|
|
1009
1142
|
}
|
|
1010
1143
|
|
|
1011
|
-
// src/
|
|
1012
|
-
|
|
1013
|
-
|
|
1144
|
+
// src/prompt.ts
|
|
1145
|
+
import { stdin, stdout } from "process";
|
|
1146
|
+
import { createInterface } from "readline/promises";
|
|
1147
|
+
var PromptAborted = class extends Error {
|
|
1148
|
+
constructor() {
|
|
1149
|
+
super("aborted");
|
|
1150
|
+
this.name = "PromptAborted";
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
function promptLine(label, def, color = false) {
|
|
1154
|
+
const c = palette(color);
|
|
1155
|
+
const clean = def ? sanitizeValue(def) : def;
|
|
1156
|
+
return ` ${c.faint}${label}${c.reset}${clean ? ` [${clean}]` : ""}: `;
|
|
1157
|
+
}
|
|
1158
|
+
function matchChoice(answer, keys, def) {
|
|
1159
|
+
if (keys.some((k) => k.length !== 1) || new Set(keys).size !== keys.length) {
|
|
1160
|
+
throw new Error(`choice keys must be unique single letters: ${keys.join(",")}`);
|
|
1161
|
+
}
|
|
1162
|
+
const a = answer.trim().toLowerCase();
|
|
1163
|
+
if (a === "") return def;
|
|
1164
|
+
const first = a[0];
|
|
1165
|
+
return keys.includes(first) ? first : null;
|
|
1014
1166
|
}
|
|
1167
|
+
function makePrompter() {
|
|
1168
|
+
let rl = null;
|
|
1169
|
+
const color = colorEnabled();
|
|
1170
|
+
const c = palette(color);
|
|
1171
|
+
let ac = null;
|
|
1172
|
+
const io = () => {
|
|
1173
|
+
if (!rl) {
|
|
1174
|
+
rl = createInterface({ input: stdin, output: stdout });
|
|
1175
|
+
rl.on("SIGINT", () => {
|
|
1176
|
+
if (ac) ac.abort();
|
|
1177
|
+
else {
|
|
1178
|
+
stdout.write("\n");
|
|
1179
|
+
process.exit(130);
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
rl.on("close", () => {
|
|
1183
|
+
ac?.abort();
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
return rl;
|
|
1187
|
+
};
|
|
1188
|
+
const question = async (query) => {
|
|
1189
|
+
const controller = new AbortController();
|
|
1190
|
+
ac = controller;
|
|
1191
|
+
try {
|
|
1192
|
+
return await io().question(query, { signal: controller.signal });
|
|
1193
|
+
} catch (e) {
|
|
1194
|
+
if (e instanceof Error && e.name === "AbortError") throw new PromptAborted();
|
|
1195
|
+
throw e;
|
|
1196
|
+
} finally {
|
|
1197
|
+
ac = null;
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
return {
|
|
1201
|
+
async ask(label, def) {
|
|
1202
|
+
const clean = def ? sanitizeValue(def) : def;
|
|
1203
|
+
const answer = (await question(promptLine(label, def, color))).trim();
|
|
1204
|
+
return answer === "" ? clean ?? "" : answer;
|
|
1205
|
+
},
|
|
1206
|
+
// Prompts are output units (render.ts convention): confirm/choice open with the unit's one
|
|
1207
|
+
// blank line here — never in the caller's question string. Field ask()s stay tight: the
|
|
1208
|
+
// 13-key walk is a single unit opened by its hint line.
|
|
1209
|
+
async confirm(q, defYes) {
|
|
1210
|
+
const answer = (await question(`
|
|
1211
|
+
${q} ${c.faint}[${defYes ? "Y/n" : "y/N"}]${c.reset} `)).trim().toLowerCase();
|
|
1212
|
+
if (answer === "") return defYes;
|
|
1213
|
+
return answer === "y" || answer === "yes";
|
|
1214
|
+
},
|
|
1215
|
+
async choice(q, keys, def, hint) {
|
|
1216
|
+
let prefix = "\n";
|
|
1217
|
+
for (; ; ) {
|
|
1218
|
+
const hit = matchChoice(
|
|
1219
|
+
await question(`${prefix} ${q} ${c.faint}[${hint}]${c.reset} `),
|
|
1220
|
+
keys,
|
|
1221
|
+
def
|
|
1222
|
+
);
|
|
1223
|
+
if (hit !== null) return hit;
|
|
1224
|
+
prefix = "";
|
|
1225
|
+
}
|
|
1226
|
+
},
|
|
1227
|
+
close() {
|
|
1228
|
+
rl?.close();
|
|
1229
|
+
rl = null;
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// src/commands.ts
|
|
1015
1235
|
function requireHandle(cred) {
|
|
1016
1236
|
if (cred.handle) return cred.handle;
|
|
1017
1237
|
console.error(
|
|
1018
|
-
|
|
1238
|
+
message(
|
|
1239
|
+
"Your GitHub username is a reserved word, so no handle is bound. Rename on GitHub, then run `ymmv login` again."
|
|
1240
|
+
)
|
|
1019
1241
|
);
|
|
1020
1242
|
process.exitCode = 1;
|
|
1021
1243
|
return null;
|
|
@@ -1023,7 +1245,7 @@ function requireHandle(cred) {
|
|
|
1023
1245
|
function assertHandleUnchanged(existing, handle) {
|
|
1024
1246
|
if (existing && existing.handle.toLowerCase() !== handle.toLowerCase()) {
|
|
1025
1247
|
throw new Error(
|
|
1026
|
-
`
|
|
1248
|
+
`This login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}". Run \`ymmv login\` to refresh, then retry.`
|
|
1027
1249
|
);
|
|
1028
1250
|
}
|
|
1029
1251
|
}
|
|
@@ -1036,18 +1258,30 @@ function newProfile(handle, entries, extras) {
|
|
|
1036
1258
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1037
1259
|
};
|
|
1038
1260
|
}
|
|
1261
|
+
function printPublished(res, color) {
|
|
1262
|
+
console.log(message(`Published ${res.handle} \u2192 ${link(res.url, color)}`));
|
|
1263
|
+
}
|
|
1264
|
+
function pagePointer(handle) {
|
|
1265
|
+
const color = colorEnabled();
|
|
1266
|
+
const c = palette(color);
|
|
1267
|
+
return ` ${c.faint}\u2192 ${color ? displayUrl(BASE) : BASE}/${handle}${c.reset}`;
|
|
1268
|
+
}
|
|
1039
1269
|
async function promptEntries(defaults, prompter) {
|
|
1270
|
+
const c = palette(colorEnabled());
|
|
1271
|
+
console.log(message(`${c.faint}Enter to keep, "-" to clear${c.reset}`));
|
|
1040
1272
|
const chosen = /* @__PURE__ */ new Map();
|
|
1041
1273
|
for (const key of CURATED_KEYS) {
|
|
1042
1274
|
const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
|
|
1043
1275
|
const value = answer === "-" ? "" : answer;
|
|
1044
1276
|
if (value) chosen.set(key, value);
|
|
1045
1277
|
}
|
|
1046
|
-
return
|
|
1278
|
+
return chosen;
|
|
1047
1279
|
}
|
|
1048
1280
|
async function publish(io) {
|
|
1049
1281
|
if (!io.interactive && !io.yes) {
|
|
1050
|
-
console.error(
|
|
1282
|
+
console.error(
|
|
1283
|
+
message("Non-interactive publish needs -y (nothing publishes unconfirmed): ymmv -y")
|
|
1284
|
+
);
|
|
1051
1285
|
process.exitCode = 1;
|
|
1052
1286
|
return;
|
|
1053
1287
|
}
|
|
@@ -1065,44 +1299,76 @@ async function publish(io) {
|
|
|
1065
1299
|
assertHandleUnchanged(existing, handle);
|
|
1066
1300
|
const defaults = buildDefaults(existing, detected);
|
|
1067
1301
|
const carried = unknownEntries(existing);
|
|
1068
|
-
let entries = entriesFromMap(defaults);
|
|
1069
1302
|
const extras = existing?.extras ?? [];
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1303
|
+
const color = colorEnabled();
|
|
1304
|
+
const site = displayUrl(BASE);
|
|
1305
|
+
const showCard = (entries) => {
|
|
1306
|
+
console.log(
|
|
1307
|
+
renderProfile(newProfile(handle, entries, extras), { color, site, mode: "preview" })
|
|
1308
|
+
);
|
|
1309
|
+
const notes = [];
|
|
1310
|
+
if (carried.length > 0) {
|
|
1311
|
+
const s = carried.length === 1 ? "" : "s";
|
|
1312
|
+
notes.push(`(+${carried.length} newer field${s} kept as-is; upgrade ymmv-cli to edit them)`);
|
|
1313
|
+
for (const e of carried) {
|
|
1314
|
+
notes.push(` ${sanitizeValue(e.key)} = ${sanitizeValue(e.value)}`);
|
|
1315
|
+
}
|
|
1081
1316
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1317
|
+
const publishedLabels = new Set(
|
|
1318
|
+
entries.filter((e) => isCuratedKey(e.key)).map((e) => KEY_LABELS[e.key].toLowerCase())
|
|
1319
|
+
);
|
|
1320
|
+
for (const x of extras) {
|
|
1321
|
+
if (publishedLabels.has(x.label.trim().toLowerCase())) {
|
|
1322
|
+
const label = sanitizeValue(x.label.trim());
|
|
1323
|
+
notes.push(`(extra "${label}" duplicates a curated field; ymmv unset --extra "${label}")`);
|
|
1324
|
+
}
|
|
1090
1325
|
}
|
|
1326
|
+
if (notes.length > 0) console.log(message(notes.join("\n")));
|
|
1327
|
+
};
|
|
1328
|
+
let values = defaults;
|
|
1329
|
+
const assemble = () => [...entriesFromMap(values), ...carried];
|
|
1330
|
+
if (!io.interactive || !io.prompter || io.yes) {
|
|
1331
|
+
const entries = assemble();
|
|
1332
|
+
showCard(entries);
|
|
1333
|
+
printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
|
|
1334
|
+
return;
|
|
1091
1335
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1336
|
+
try {
|
|
1337
|
+
if (!existing) values = await promptEntries(values, io.prompter);
|
|
1338
|
+
for (; ; ) {
|
|
1339
|
+
const entries = assemble();
|
|
1340
|
+
showCard(entries);
|
|
1341
|
+
const ans = await io.prompter.choice(
|
|
1342
|
+
`Publish to ${site}/${sanitizeValue(handle)}?`,
|
|
1343
|
+
["y", "n", "e"],
|
|
1344
|
+
"y",
|
|
1345
|
+
"Y/n/e=edit"
|
|
1346
|
+
);
|
|
1347
|
+
if (ans === "y") {
|
|
1348
|
+
printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
if (ans === "n") {
|
|
1352
|
+
console.log(message("Aborted. Nothing published."));
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
values = await promptEntries(values, io.prompter);
|
|
1356
|
+
}
|
|
1357
|
+
} catch (e) {
|
|
1358
|
+
if (e instanceof PromptAborted) {
|
|
1359
|
+
console.log(`
|
|
1360
|
+
${message("Aborted. Nothing published.")}`);
|
|
1361
|
+
process.exitCode = 130;
|
|
1096
1362
|
return;
|
|
1097
1363
|
}
|
|
1364
|
+
throw e;
|
|
1098
1365
|
}
|
|
1099
|
-
await publishProfile(profile);
|
|
1100
1366
|
}
|
|
1101
1367
|
async function view(handle) {
|
|
1102
1368
|
const theirs = await fetchProfileJson(handle);
|
|
1103
1369
|
const c = colorEnabled();
|
|
1104
1370
|
if (!theirs) {
|
|
1105
|
-
console.log(notFound(handle));
|
|
1371
|
+
console.log(notFound(handle, c, BASE));
|
|
1106
1372
|
return;
|
|
1107
1373
|
}
|
|
1108
1374
|
const cred = await loadToken();
|
|
@@ -1115,12 +1381,12 @@ async function view(handle) {
|
|
|
1115
1381
|
return;
|
|
1116
1382
|
}
|
|
1117
1383
|
if (!mine) {
|
|
1118
|
-
console.log(renderProfile(theirs, { color: c }));
|
|
1384
|
+
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
1119
1385
|
console.log(nudge(c));
|
|
1120
1386
|
return;
|
|
1121
1387
|
}
|
|
1122
1388
|
}
|
|
1123
|
-
console.log(renderProfile(theirs, { color: c }));
|
|
1389
|
+
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
1124
1390
|
}
|
|
1125
1391
|
async function runSet(target) {
|
|
1126
1392
|
const cred = await ensureLogin();
|
|
@@ -1129,12 +1395,9 @@ async function runSet(target) {
|
|
|
1129
1395
|
const existing = await fetchProfileJson(handle);
|
|
1130
1396
|
assertHandleUnchanged(existing, handle);
|
|
1131
1397
|
const { entries, extras } = applySet(existing, target);
|
|
1132
|
-
await publishProfile(newProfile(handle, entries, extras));
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
} else {
|
|
1136
|
-
console.log(`Set extra ${target.label} = ${target.value}.`);
|
|
1137
|
-
}
|
|
1398
|
+
const res = await publishProfile(newProfile(handle, entries, extras));
|
|
1399
|
+
const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
|
|
1400
|
+
console.log(message(`${line}${pagePointer(res.handle)}`));
|
|
1138
1401
|
}
|
|
1139
1402
|
async function runUnset(target) {
|
|
1140
1403
|
const cred = await ensureLogin();
|
|
@@ -1143,76 +1406,55 @@ async function runUnset(target) {
|
|
|
1143
1406
|
const existing = await fetchProfileJson(handle);
|
|
1144
1407
|
assertHandleUnchanged(existing, handle);
|
|
1145
1408
|
if (!existing) {
|
|
1146
|
-
console.log("No profile yet
|
|
1409
|
+
console.log(message("No profile yet. Run `ymmv` to publish one."));
|
|
1147
1410
|
return;
|
|
1148
1411
|
}
|
|
1149
1412
|
const { entries, extras, removed } = applyUnset(existing, target);
|
|
1150
1413
|
if (!removed) {
|
|
1151
1414
|
console.log(
|
|
1152
|
-
|
|
1415
|
+
message(
|
|
1416
|
+
target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${target.label}".`
|
|
1417
|
+
)
|
|
1153
1418
|
);
|
|
1154
1419
|
return;
|
|
1155
1420
|
}
|
|
1156
|
-
await publishProfile(newProfile(handle, entries, extras));
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
} else {
|
|
1160
|
-
console.log(
|
|
1161
|
-
`Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`
|
|
1162
|
-
);
|
|
1163
|
-
}
|
|
1421
|
+
const res = await publishProfile(newProfile(handle, entries, extras));
|
|
1422
|
+
const line = target.kind === "curated" ? `Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").` : `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`;
|
|
1423
|
+
console.log(message(`${line}${pagePointer(res.handle)}`));
|
|
1164
1424
|
}
|
|
1165
1425
|
async function runDelete(io) {
|
|
1166
1426
|
const cred = await ensureLogin();
|
|
1167
|
-
const target = cred.handle ?
|
|
1427
|
+
const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
|
|
1168
1428
|
if (!io.yes) {
|
|
1169
1429
|
if (!io.interactive || !io.prompter) {
|
|
1170
1430
|
console.error(
|
|
1171
|
-
|
|
1431
|
+
message(
|
|
1432
|
+
`Refusing to delete ${target} without confirmation. Re-run with -y to confirm: ymmv delete -y`
|
|
1433
|
+
)
|
|
1172
1434
|
);
|
|
1173
1435
|
process.exitCode = 1;
|
|
1174
1436
|
return;
|
|
1175
1437
|
}
|
|
1176
|
-
|
|
1438
|
+
let go;
|
|
1439
|
+
try {
|
|
1440
|
+
go = await io.prompter.confirm(`Delete ${target}? This is permanent`, false);
|
|
1441
|
+
} catch (e) {
|
|
1442
|
+
if (e instanceof PromptAborted) {
|
|
1443
|
+
console.log(`
|
|
1444
|
+
${message("Cancelled. Nothing deleted.")}`);
|
|
1445
|
+
process.exitCode = 130;
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
throw e;
|
|
1449
|
+
}
|
|
1177
1450
|
if (!go) {
|
|
1178
|
-
console.log("Cancelled
|
|
1451
|
+
console.log(message("Cancelled. Nothing deleted."));
|
|
1179
1452
|
return;
|
|
1180
1453
|
}
|
|
1181
1454
|
}
|
|
1182
1455
|
await deleteProfile();
|
|
1183
1456
|
await deleteToken();
|
|
1184
|
-
console.log(`Deleted ${target}. Run \`ymmv\` to publish again.`);
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
// src/prompt.ts
|
|
1188
|
-
import { stdin, stdout } from "process";
|
|
1189
|
-
import { createInterface } from "readline/promises";
|
|
1190
|
-
function promptLine(label, def) {
|
|
1191
|
-
const clean = def ? sanitizeValue(def) : def;
|
|
1192
|
-
return ` ${label}${clean ? ` [${clean}]` : ""}: `;
|
|
1193
|
-
}
|
|
1194
|
-
function makePrompter() {
|
|
1195
|
-
let rl = null;
|
|
1196
|
-
const io = () => {
|
|
1197
|
-
rl ??= createInterface({ input: stdin, output: stdout });
|
|
1198
|
-
return rl;
|
|
1199
|
-
};
|
|
1200
|
-
return {
|
|
1201
|
-
async ask(label, def) {
|
|
1202
|
-
const clean = def ? sanitizeValue(def) : def;
|
|
1203
|
-
const answer = (await io().question(promptLine(label, def))).trim();
|
|
1204
|
-
return answer === "" ? clean ?? "" : answer;
|
|
1205
|
-
},
|
|
1206
|
-
async confirm(question, defYes) {
|
|
1207
|
-
const answer = (await io().question(` ${question} ${defYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
1208
|
-
if (answer === "") return defYes;
|
|
1209
|
-
return answer === "y" || answer === "yes";
|
|
1210
|
-
},
|
|
1211
|
-
close() {
|
|
1212
|
-
rl?.close();
|
|
1213
|
-
rl = null;
|
|
1214
|
-
}
|
|
1215
|
-
};
|
|
1457
|
+
console.log(message(`Deleted ${target}. Run \`ymmv\` to publish again.`));
|
|
1216
1458
|
}
|
|
1217
1459
|
|
|
1218
1460
|
// src/resolve.ts
|
|
@@ -1288,7 +1530,7 @@ function resolveArg(argv) {
|
|
|
1288
1530
|
return { kind: "view", handle };
|
|
1289
1531
|
}
|
|
1290
1532
|
if (first.startsWith("-")) {
|
|
1291
|
-
return { kind: "error", message: `
|
|
1533
|
+
return { kind: "error", message: `Unknown option "${first}". Run \`ymmv help\`.` };
|
|
1292
1534
|
}
|
|
1293
1535
|
if (!isValidHandle(first)) {
|
|
1294
1536
|
return {
|
|
@@ -1300,12 +1542,12 @@ function resolveArg(argv) {
|
|
|
1300
1542
|
}
|
|
1301
1543
|
|
|
1302
1544
|
// src/index.ts
|
|
1303
|
-
var
|
|
1545
|
+
var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
|
|
1304
1546
|
|
|
1305
|
-
Usage
|
|
1547
|
+
${c.faint}Usage:${c.reset}
|
|
1306
1548
|
ymmv detect your stack, confirm, and publish your profile
|
|
1307
1549
|
ymmv -y publish without prompts (required when stdin isn't a TTY)
|
|
1308
|
-
ymmv <handle> view a profile
|
|
1550
|
+
ymmv <handle> view a profile; logged in, see the diff vs yours
|
|
1309
1551
|
ymmv view <handle> explicit view (when a handle collides with a verb)
|
|
1310
1552
|
ymmv set <key> <value> set one curated key
|
|
1311
1553
|
ymmv set --extra "L=V" set a free-form extra
|
|
@@ -1315,17 +1557,16 @@ Usage:
|
|
|
1315
1557
|
ymmv login | logout GitHub device-flow auth
|
|
1316
1558
|
ymmv help | --version
|
|
1317
1559
|
|
|
1318
|
-
Curated keys
|
|
1319
|
-
font, theme, multiplexer, version-manager, dotfiles, ai-tool
|
|
1320
|
-
|
|
1321
|
-
Respects NO_COLOR. Point YMMV_API at a dev Worker to target one.
|
|
1322
|
-
Publish your own: npx ymmv-cli`;
|
|
1560
|
+
${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser, window-manager,
|
|
1561
|
+
font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
|
|
1323
1562
|
async function logout() {
|
|
1324
1563
|
const stored = await loadToken();
|
|
1325
1564
|
if (!stored) {
|
|
1326
1565
|
const otherBase = await peekBase();
|
|
1327
1566
|
console.log(
|
|
1328
|
-
|
|
1567
|
+
message(
|
|
1568
|
+
otherBase && otherBase !== BASE ? `Not logged in to ${BASE} (a token for ${otherBase} exists; set YMMV_API to that to log out of it).` : "Not logged in."
|
|
1569
|
+
)
|
|
1329
1570
|
);
|
|
1330
1571
|
return;
|
|
1331
1572
|
}
|
|
@@ -1334,13 +1575,15 @@ async function logout() {
|
|
|
1334
1575
|
revoked = await revokeYmmvToken(stored.token);
|
|
1335
1576
|
} catch {
|
|
1336
1577
|
console.error(
|
|
1337
|
-
|
|
1578
|
+
message(
|
|
1579
|
+
"Couldn't reach the server to revoke. Your token is still active. Run `ymmv logout` again when connected."
|
|
1580
|
+
)
|
|
1338
1581
|
);
|
|
1339
1582
|
process.exitCode = 1;
|
|
1340
1583
|
return;
|
|
1341
1584
|
}
|
|
1342
1585
|
await deleteToken();
|
|
1343
|
-
console.log(revoked ? "Logged out." : "Logged out (no active session on this server).");
|
|
1586
|
+
console.log(message(revoked ? "Logged out." : "Logged out (no active session on this server)."));
|
|
1344
1587
|
}
|
|
1345
1588
|
function printVersion() {
|
|
1346
1589
|
try {
|
|
@@ -1377,20 +1620,23 @@ async function main(argv) {
|
|
|
1377
1620
|
case "delete":
|
|
1378
1621
|
await interactive(runDelete, cmd.yes);
|
|
1379
1622
|
break;
|
|
1380
|
-
case "login":
|
|
1623
|
+
case "login": {
|
|
1381
1624
|
await login();
|
|
1625
|
+
const c = palette(colorEnabled());
|
|
1626
|
+
console.log(message(`${c.faint}next: run ymmv to publish your stack${c.reset}`));
|
|
1382
1627
|
break;
|
|
1628
|
+
}
|
|
1383
1629
|
case "logout":
|
|
1384
1630
|
await logout();
|
|
1385
1631
|
break;
|
|
1386
1632
|
case "help":
|
|
1387
|
-
console.log(
|
|
1633
|
+
console.log(help(palette(colorEnabled())));
|
|
1388
1634
|
break;
|
|
1389
1635
|
case "version":
|
|
1390
1636
|
printVersion();
|
|
1391
1637
|
break;
|
|
1392
1638
|
case "error":
|
|
1393
|
-
console.error(cmd.message);
|
|
1639
|
+
console.error(message(cmd.message));
|
|
1394
1640
|
process.exitCode = 1;
|
|
1395
1641
|
break;
|
|
1396
1642
|
}
|
|
@@ -1398,6 +1644,8 @@ async function main(argv) {
|
|
|
1398
1644
|
|
|
1399
1645
|
// src/cli.ts
|
|
1400
1646
|
main(process.argv.slice(2)).catch((err) => {
|
|
1401
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1647
|
+
console.error(message(err instanceof Error ? err.message : String(err)));
|
|
1402
1648
|
process.exitCode = 1;
|
|
1649
|
+
}).finally(() => {
|
|
1650
|
+
(process.exitCode ? process.stderr : process.stdout).write("\n");
|
|
1403
1651
|
});
|