ymmv-cli 0.5.0 → 0.6.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/README.md +7 -2
- package/dist/cli.js +536 -317
- 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",
|
|
@@ -380,6 +339,269 @@ 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
|
+
var ESC_INTRODUCERS = `${String.fromCharCode(27)}${String.fromCharCode(155)}`;
|
|
357
|
+
var BEL = String.fromCharCode(7);
|
|
358
|
+
var ANSI_RE = new RegExp(
|
|
359
|
+
`[${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=><~]))`,
|
|
360
|
+
"g"
|
|
361
|
+
);
|
|
362
|
+
var CTRL_RE = new RegExp(
|
|
363
|
+
`[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
|
|
364
|
+
"g"
|
|
365
|
+
);
|
|
366
|
+
var BIDI_RE = new RegExp(
|
|
367
|
+
`[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
|
|
368
|
+
"g"
|
|
369
|
+
);
|
|
370
|
+
function sanitizeValue(value) {
|
|
371
|
+
return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
|
|
372
|
+
}
|
|
373
|
+
function useColor(env, isTTY) {
|
|
374
|
+
if (env.NO_COLOR !== void 0) return false;
|
|
375
|
+
if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
|
|
376
|
+
if (env.TERM === "dumb") return false;
|
|
377
|
+
return isTTY;
|
|
378
|
+
}
|
|
379
|
+
function colorEnabled() {
|
|
380
|
+
return useColor(process.env, Boolean(process.stdout.isTTY));
|
|
381
|
+
}
|
|
382
|
+
var OSC = `${ESC}]`;
|
|
383
|
+
var ST = `${ESC}\\`;
|
|
384
|
+
function displayUrl(value) {
|
|
385
|
+
return value.trim().replace(/^https:\/\/(?=.)/i, "");
|
|
386
|
+
}
|
|
387
|
+
var HTTP_URL_RE = /^https?:\/\/\S+$/i;
|
|
388
|
+
function isHttpUrl(value) {
|
|
389
|
+
return HTTP_URL_RE.test(value.trim());
|
|
390
|
+
}
|
|
391
|
+
var NO_OSC8_TERMS = /* @__PURE__ */ new Set(["linux", "dumb"]);
|
|
392
|
+
function link(url, color, term = process.env.TERM) {
|
|
393
|
+
const clean = sanitizeValue(url).trim();
|
|
394
|
+
if (!color) return clean;
|
|
395
|
+
const text = `${CODES.amber}${displayUrl(clean)}${CODES.reset}`;
|
|
396
|
+
if (term !== void 0 && NO_OSC8_TERMS.has(term)) return text;
|
|
397
|
+
return `${OSC}8;;${clean}${ST}${text}${OSC}8;;${ST}`;
|
|
398
|
+
}
|
|
399
|
+
function relTime(iso, now = Date.now) {
|
|
400
|
+
const t = Date.parse(iso);
|
|
401
|
+
if (Number.isNaN(t)) return sanitizeValue(iso);
|
|
402
|
+
const ms = now() - t;
|
|
403
|
+
if (ms < 6e4) return "just now";
|
|
404
|
+
const m = Math.floor(ms / 6e4);
|
|
405
|
+
if (m < 60) return `${m}m ago`;
|
|
406
|
+
const h = Math.floor(m / 60);
|
|
407
|
+
if (h < 24) return `${h}h ago`;
|
|
408
|
+
const d = Math.floor(h / 24);
|
|
409
|
+
if (d < 30) return `${d}d ago`;
|
|
410
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
411
|
+
}
|
|
412
|
+
function renderProfile(profile, opts) {
|
|
413
|
+
const c = palette(opts.color);
|
|
414
|
+
const preview = opts.mode === "preview";
|
|
415
|
+
const byKey = new Map(
|
|
416
|
+
(profile.entries ?? []).map((e) => [e.key, e.value])
|
|
417
|
+
);
|
|
418
|
+
const rows = CURATED_KEYS.flatMap((key) => {
|
|
419
|
+
const value = byKey.get(key);
|
|
420
|
+
if (value === void 0 && !preview) return [];
|
|
421
|
+
return [{ label: KEY_LABELS[key], value: value === void 0 ? null : sanitizeValue(value) }];
|
|
422
|
+
});
|
|
423
|
+
const extras = (profile.extras ?? []).map((x) => ({
|
|
424
|
+
label: sanitizeValue(x.label),
|
|
425
|
+
value: sanitizeValue(x.value)
|
|
426
|
+
}));
|
|
427
|
+
const labelW = Math.max(
|
|
428
|
+
0,
|
|
429
|
+
...rows.map((r) => r.label.length),
|
|
430
|
+
...extras.map((x) => x.label.length)
|
|
431
|
+
);
|
|
432
|
+
const val = (v) => isHttpUrl(v) ? link(v, opts.color) : v;
|
|
433
|
+
const lines = [
|
|
434
|
+
"",
|
|
435
|
+
` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`,
|
|
436
|
+
""
|
|
437
|
+
];
|
|
438
|
+
for (const r of rows) {
|
|
439
|
+
lines.push(
|
|
440
|
+
r.value === null ? ` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}` : ` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (extras.length) {
|
|
444
|
+
lines.push("");
|
|
445
|
+
for (const x of extras) {
|
|
446
|
+
lines.push(` ${c.faint}${x.label.padEnd(labelW)}${c.reset} ${val(x.value)}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (!preview) {
|
|
450
|
+
lines.push("", ` ${c.faint}updated ${relTime(profile.updated_at, opts.now)}${c.reset}`);
|
|
451
|
+
}
|
|
452
|
+
lines.push("");
|
|
453
|
+
return lines.join("\n");
|
|
454
|
+
}
|
|
455
|
+
var MISSING = "\u2014";
|
|
456
|
+
function extrasBlock(extras, theirsLabel, mineLabel, c) {
|
|
457
|
+
if (!extras.theirs.length && !extras.mine.length) return [];
|
|
458
|
+
const out = ["", ` ${c.faint}extras${c.reset}`];
|
|
459
|
+
const line = (who, label, value) => ` ${c.faint}${who}${c.reset} ${sanitizeValue(label)} = ${sanitizeValue(value)}`;
|
|
460
|
+
for (const x of extras.theirs) out.push(line(theirsLabel, x.label, x.value));
|
|
461
|
+
for (const x of extras.mine) out.push(line(mineLabel, x.label, x.value));
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
function renderDiff(result, opts) {
|
|
465
|
+
const c = palette(opts.color);
|
|
466
|
+
const theirsLabel = sanitizeValue(opts.theirsLabel);
|
|
467
|
+
const mineLabel = sanitizeValue(opts.mineLabel);
|
|
468
|
+
const cells = result.rows.map((r) => ({
|
|
469
|
+
label: r.label,
|
|
470
|
+
theirs: r.theirs === null ? MISSING : sanitizeValue(r.theirs),
|
|
471
|
+
mine: r.mine === null ? MISSING : sanitizeValue(r.mine),
|
|
472
|
+
differ: r.status !== "same"
|
|
473
|
+
}));
|
|
474
|
+
const labelW = Math.max(3, ...cells.map((r) => r.label.length));
|
|
475
|
+
const theirsHead = theirsLabel.toUpperCase();
|
|
476
|
+
const theirsW = Math.max(theirsHead.length, ...cells.map((r) => r.theirs.length));
|
|
477
|
+
const lines = [
|
|
478
|
+
"",
|
|
479
|
+
// The web diff's h1, collapsed to one line — information (which side is which), not decoration,
|
|
480
|
+
// so it prints in both color modes.
|
|
481
|
+
` ${c.faint}how${c.reset} ${c.bold}${theirsLabel}${c.reset} ${c.faint}differs from${c.reset} ${c.bold}${mineLabel}${c.reset}`,
|
|
482
|
+
""
|
|
483
|
+
];
|
|
484
|
+
lines.push(
|
|
485
|
+
` ${c.faint}${"".padEnd(labelW)} ${theirsHead.padEnd(theirsW)} ${mineLabel.toUpperCase()}${c.reset}`
|
|
486
|
+
);
|
|
487
|
+
for (const r of cells) {
|
|
488
|
+
if (!opts.color) {
|
|
489
|
+
const sym = r.differ ? "~" : "=";
|
|
490
|
+
lines.push(`${sym} ${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}`);
|
|
491
|
+
} else if (r.differ) {
|
|
492
|
+
lines.push(
|
|
493
|
+
`${c.amber}\u2022${c.reset} ${r.label.padEnd(labelW)} ${c.amber}${r.theirs.padEnd(theirsW)}${c.reset} ${c.amber}${r.mine}${c.reset}`
|
|
494
|
+
);
|
|
495
|
+
} else {
|
|
496
|
+
lines.push(
|
|
497
|
+
` ${c.faint}${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}${c.reset}`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
lines.push(...extrasBlock(result.extras, theirsLabel, mineLabel, c));
|
|
502
|
+
lines.push("", ` ${c.faint}${result.differ} differ ${result.shared} shared${c.reset}`, "");
|
|
503
|
+
return lines.join("\n");
|
|
504
|
+
}
|
|
505
|
+
function nudge(color) {
|
|
506
|
+
const c = palette(color);
|
|
507
|
+
return `
|
|
508
|
+
${c.amber}publish yours to diff \u2192${c.reset} run ${c.bold}ymmv${c.reset}
|
|
509
|
+
`;
|
|
510
|
+
}
|
|
511
|
+
function notFound(handle, color, base) {
|
|
512
|
+
return `
|
|
513
|
+
no ymmv profile for "${sanitizeValue(handle)}" yet.
|
|
514
|
+
publish one at ${link(base, color)} with: npx ymmv-cli
|
|
515
|
+
`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/http.ts
|
|
519
|
+
function causeText(err) {
|
|
520
|
+
const pick = (e) => {
|
|
521
|
+
if (e instanceof AggregateError) {
|
|
522
|
+
const first = e.errors.find((x) => x instanceof Error && x.message.length > 0);
|
|
523
|
+
if (first) return first.message;
|
|
524
|
+
const code = e.code;
|
|
525
|
+
if (code) return String(code);
|
|
526
|
+
}
|
|
527
|
+
return e.message;
|
|
528
|
+
};
|
|
529
|
+
let text;
|
|
530
|
+
if (err instanceof Error) {
|
|
531
|
+
const fromCause = err.cause instanceof Error ? pick(err.cause) : "";
|
|
532
|
+
text = fromCause || pick(err) || String(err);
|
|
533
|
+
} else {
|
|
534
|
+
text = String(err);
|
|
535
|
+
}
|
|
536
|
+
return sanitizeValue(text);
|
|
537
|
+
}
|
|
538
|
+
function wireText(text) {
|
|
539
|
+
const clean = sanitizeValue(String(text));
|
|
540
|
+
return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
|
|
541
|
+
}
|
|
542
|
+
async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
543
|
+
try {
|
|
544
|
+
return await fetchFn(url, init);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
throw new Error(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
|
|
547
|
+
cause: err
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// src/auth-http.ts
|
|
553
|
+
async function mintYmmvToken(accessToken) {
|
|
554
|
+
const res = await safeFetch(
|
|
555
|
+
`${BASE}/api/v1/auth/token`,
|
|
556
|
+
{
|
|
557
|
+
method: "POST",
|
|
558
|
+
headers: { "content-type": "application/json" },
|
|
559
|
+
body: JSON.stringify({ access_token: accessToken }),
|
|
560
|
+
// Never follow a redirect: a 30x must fail (the existing `!res.ok` guard rejects the resulting
|
|
561
|
+
// opaqueredirect), not re-POST the GitHub access_token to the redirect target or read a
|
|
562
|
+
// redirected 200 as a successful mint. Mirrors publish/delete in api.ts.
|
|
563
|
+
redirect: "manual"
|
|
564
|
+
},
|
|
565
|
+
BASE
|
|
566
|
+
);
|
|
567
|
+
if (!res.ok) {
|
|
568
|
+
const body = await res.json().catch(() => ({}));
|
|
569
|
+
if (res.status === 503) {
|
|
570
|
+
throw new Error(
|
|
571
|
+
body.message ? wireText(body.message) : "GitHub is unavailable. Run `ymmv login` again shortly."
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (res.status === 429) {
|
|
575
|
+
const retry = res.headers.get("retry-after");
|
|
576
|
+
const msg = body.message ? wireText(body.message) : "Too many login attempts. Slow down and try again shortly";
|
|
577
|
+
throw new Error(retry ? `${msg} (retry in ${retry}s)` : msg);
|
|
578
|
+
}
|
|
579
|
+
throw new Error(`login failed: ${res.status} ${wireText(body.error ?? "")}`.trim());
|
|
580
|
+
}
|
|
581
|
+
const data = await res.json().catch(() => null);
|
|
582
|
+
if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
|
|
588
|
+
}
|
|
589
|
+
async function revokeYmmvToken(token) {
|
|
590
|
+
const res = await fetch(`${BASE}/api/v1/auth/logout`, {
|
|
591
|
+
method: "POST",
|
|
592
|
+
headers: { authorization: `Bearer ${token}` },
|
|
593
|
+
// Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete the
|
|
594
|
+
// local file while the server token stays live). Same guard as mint + publish/delete.
|
|
595
|
+
redirect: "manual"
|
|
596
|
+
});
|
|
597
|
+
if (!res.ok) throw new Error(`logout failed: ${res.status}`);
|
|
598
|
+
const body = await res.json().catch(() => ({}));
|
|
599
|
+
return body.revoked === true;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/commands.ts
|
|
603
|
+
import { readFileSync, statSync } from "fs";
|
|
604
|
+
|
|
383
605
|
// src/token-store.ts
|
|
384
606
|
import { randomUUID } from "crypto";
|
|
385
607
|
import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
@@ -440,15 +662,24 @@ var TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
|
440
662
|
var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
441
663
|
async function requestDeviceCode(deps = {}) {
|
|
442
664
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
443
|
-
const res = await
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
665
|
+
const res = await safeFetch(
|
|
666
|
+
DEVICE_CODE_URL,
|
|
667
|
+
{
|
|
668
|
+
method: "POST",
|
|
669
|
+
headers: { accept: "application/json" },
|
|
670
|
+
body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID })
|
|
671
|
+
},
|
|
672
|
+
"github.com",
|
|
673
|
+
doFetch
|
|
674
|
+
);
|
|
448
675
|
if (!res.ok) {
|
|
449
|
-
throw new Error(`device code request failed: ${res.status} ${await res.text()}`);
|
|
676
|
+
throw new Error(`device code request failed: ${res.status} ${wireText(await res.text())}`);
|
|
450
677
|
}
|
|
451
|
-
|
|
678
|
+
const data = await res.json().catch(() => null);
|
|
679
|
+
if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.expires_in !== "number") {
|
|
680
|
+
throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
|
|
681
|
+
}
|
|
682
|
+
return data;
|
|
452
683
|
}
|
|
453
684
|
async function pollForToken(dc, deps = {}) {
|
|
454
685
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
@@ -457,30 +688,39 @@ async function pollForToken(dc, deps = {}) {
|
|
|
457
688
|
let interval = dc.interval || 5;
|
|
458
689
|
const deadline = now() + dc.expires_in * 1e3;
|
|
459
690
|
let transientFailures = 0;
|
|
691
|
+
let lastCause = "";
|
|
460
692
|
const MAX_TRANSIENT_FAILURES = 5;
|
|
461
693
|
while (now() < deadline) {
|
|
462
694
|
await sleep(interval * 1e3);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
695
|
+
let res;
|
|
696
|
+
try {
|
|
697
|
+
res = await doFetch(TOKEN_URL, {
|
|
698
|
+
method: "POST",
|
|
699
|
+
headers: { accept: "application/json" },
|
|
700
|
+
body: new URLSearchParams({
|
|
701
|
+
client_id: GITHUB_CLIENT_ID,
|
|
702
|
+
device_code: dc.device_code,
|
|
703
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
704
|
+
})
|
|
705
|
+
});
|
|
706
|
+
} catch (err) {
|
|
707
|
+
lastCause = causeText(err);
|
|
708
|
+
}
|
|
472
709
|
let tok;
|
|
473
|
-
if (res
|
|
710
|
+
if (res?.ok) {
|
|
474
711
|
try {
|
|
475
712
|
tok = await res.json();
|
|
476
713
|
} catch {
|
|
477
714
|
tok = void 0;
|
|
715
|
+
lastCause = "unexpected response body";
|
|
478
716
|
}
|
|
717
|
+
} else if (res) {
|
|
718
|
+
lastCause = `HTTP ${res.status}`;
|
|
479
719
|
}
|
|
480
720
|
if (tok === void 0) {
|
|
481
721
|
if (++transientFailures >= MAX_TRANSIENT_FAILURES) {
|
|
482
722
|
throw new Error(
|
|
483
|
-
|
|
723
|
+
`GitHub isn't responding to the login poll. Check your connection and run \`ymmv login\` again.${lastCause ? ` (last error: ${lastCause})` : ""}`
|
|
484
724
|
);
|
|
485
725
|
}
|
|
486
726
|
continue;
|
|
@@ -496,22 +736,28 @@ async function pollForToken(dc, deps = {}) {
|
|
|
496
736
|
case "access_denied":
|
|
497
737
|
throw new Error("Authorization denied. Run `ymmv login` to try again.");
|
|
498
738
|
case "expired_token":
|
|
499
|
-
throw new Error("Device code expired
|
|
739
|
+
throw new Error("Device code expired. Run `ymmv login` again.");
|
|
500
740
|
default:
|
|
501
|
-
throw new Error(`device flow failed: ${tok.error ?? "unknown error"}`);
|
|
741
|
+
throw new Error(`device flow failed: ${wireText(tok.error ?? "unknown error")}`);
|
|
502
742
|
}
|
|
503
743
|
}
|
|
504
|
-
throw new Error("Device code expired
|
|
744
|
+
throw new Error("Device code expired. Run `ymmv login` again.");
|
|
505
745
|
}
|
|
506
746
|
async function login(deps = {}) {
|
|
507
747
|
if (!process.stdin.isTTY) {
|
|
508
748
|
throw new Error(
|
|
509
|
-
"Device login needs an interactive terminal
|
|
749
|
+
"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
750
|
);
|
|
511
751
|
}
|
|
512
752
|
const dc = await requestDeviceCode(deps);
|
|
513
|
-
|
|
514
|
-
|
|
753
|
+
const color = colorEnabled();
|
|
754
|
+
const c = palette(color);
|
|
755
|
+
const verifyUri = /^https:\/\/github\.com\//.test(dc.verification_uri) ? link(dc.verification_uri, color) : sanitizeValue(dc.verification_uri);
|
|
756
|
+
console.log(
|
|
757
|
+
`
|
|
758
|
+
Open ${verifyUri} and enter code: ${c.bold}${sanitizeValue(dc.user_code)}${c.reset}`
|
|
759
|
+
);
|
|
760
|
+
console.log(` ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}
|
|
515
761
|
`);
|
|
516
762
|
const accessToken = await pollForToken(dc, deps);
|
|
517
763
|
const { token, handle } = await mintYmmvToken(accessToken);
|
|
@@ -524,147 +770,17 @@ async function login(deps = {}) {
|
|
|
524
770
|
}
|
|
525
771
|
console.log(
|
|
526
772
|
handle ? ` Logged in as ${handle}.
|
|
527
|
-
` : " Logged in
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
|
|
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])
|
|
773
|
+
` : " Logged in. No handle bound (your GitHub username is a reserved word).\n"
|
|
570
774
|
);
|
|
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
775
|
}
|
|
660
776
|
|
|
661
777
|
// src/api.ts
|
|
662
778
|
async function rateLimitMessage(res) {
|
|
663
779
|
const retry = res.headers.get("retry-after");
|
|
664
|
-
let msg = "rate limited
|
|
780
|
+
let msg = "rate limited, too many requests";
|
|
665
781
|
try {
|
|
666
782
|
const body = await res.json();
|
|
667
|
-
if (typeof body?.message === "string" && body.message) msg = body.message;
|
|
783
|
+
if (typeof body?.message === "string" && body.message) msg = wireText(body.message);
|
|
668
784
|
} catch {
|
|
669
785
|
}
|
|
670
786
|
return retry ? `${msg} (retry in ${retry}s)` : msg;
|
|
@@ -674,32 +790,43 @@ async function ensureLogin() {
|
|
|
674
790
|
if (existing) return existing;
|
|
675
791
|
await login();
|
|
676
792
|
const fresh = await loadToken();
|
|
677
|
-
if (!fresh) throw new Error("
|
|
793
|
+
if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
|
|
678
794
|
return fresh;
|
|
679
795
|
}
|
|
680
796
|
async function publishProfile(profile) {
|
|
681
|
-
const send = (c) =>
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
797
|
+
const send = (c) => safeFetch(
|
|
798
|
+
`${BASE}/api/v1/profile`,
|
|
799
|
+
{
|
|
800
|
+
method: "POST",
|
|
801
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${c.token}` },
|
|
802
|
+
// Send the login-bound handle, never a caller-guessed one — the official client never claims
|
|
803
|
+
// a handle it doesn't own.
|
|
804
|
+
body: JSON.stringify({ ...profile, handle: c.handle ?? profile.handle }),
|
|
805
|
+
redirect: "manual"
|
|
806
|
+
// a mutation must never follow a redirect into a false success
|
|
807
|
+
},
|
|
808
|
+
BASE
|
|
809
|
+
);
|
|
690
810
|
let cred = await ensureLogin();
|
|
691
811
|
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
692
812
|
throw new Error(
|
|
693
|
-
"
|
|
813
|
+
"The stored login changed while this command was running. Re-run it under the current account."
|
|
694
814
|
);
|
|
695
815
|
}
|
|
696
816
|
let res = await send(cred);
|
|
697
817
|
if (res.status === 401 || res.status === 409) {
|
|
698
|
-
|
|
818
|
+
const was401 = res.status === 401;
|
|
819
|
+
if (was401) await deleteToken();
|
|
699
820
|
await login();
|
|
700
821
|
cred = await ensureLogin();
|
|
822
|
+
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
823
|
+
const bound = sanitizeValue(cred.handle ?? "");
|
|
824
|
+
throw new Error(
|
|
825
|
+
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.`
|
|
826
|
+
);
|
|
827
|
+
}
|
|
701
828
|
res = await send(cred);
|
|
702
|
-
if (res.status === 401) throw new Error("
|
|
829
|
+
if (res.status === 401) throw new Error("Authentication failed. Run `ymmv login`.");
|
|
703
830
|
if (res.status === 409) {
|
|
704
831
|
throw new Error(
|
|
705
832
|
"that handle is taken by another account (your GitHub handle may have been reused)."
|
|
@@ -708,33 +835,37 @@ async function publishProfile(profile) {
|
|
|
708
835
|
}
|
|
709
836
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
710
837
|
if (!res.ok) {
|
|
711
|
-
throw new Error(`publish failed: ${res.status} ${await res.text()}`);
|
|
838
|
+
throw new Error(`publish failed: ${res.status} ${wireText(await res.text())}`);
|
|
712
839
|
}
|
|
713
840
|
const data = await res.json();
|
|
714
841
|
const shown = typeof data.handle === "string" ? sanitizeValue(data.handle) : profile.handle;
|
|
715
|
-
|
|
842
|
+
return { handle: shown, url: `${BASE}/${shown}` };
|
|
716
843
|
}
|
|
717
844
|
async function fetchProfileJson(handle) {
|
|
718
|
-
const res = await
|
|
845
|
+
const res = await safeFetch(`${BASE}/api/v1/u/${encodeURIComponent(handle)}`, void 0, BASE);
|
|
719
846
|
if (res.status === 404) return null;
|
|
720
847
|
if (!res.ok) {
|
|
721
|
-
throw new Error(`fetch failed: ${res.status} ${await res.text()}`);
|
|
848
|
+
throw new Error(`fetch failed: ${res.status} ${wireText(await res.text())}`);
|
|
722
849
|
}
|
|
723
850
|
return parseProfile(await res.json());
|
|
724
851
|
}
|
|
725
852
|
async function deleteProfile() {
|
|
726
853
|
const cred = await ensureLogin();
|
|
727
|
-
const res = await
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
854
|
+
const res = await safeFetch(
|
|
855
|
+
`${BASE}/api/v1/profile`,
|
|
856
|
+
{
|
|
857
|
+
method: "DELETE",
|
|
858
|
+
headers: { authorization: `Bearer ${cred.token}` },
|
|
859
|
+
redirect: "manual"
|
|
860
|
+
},
|
|
861
|
+
BASE
|
|
862
|
+
);
|
|
732
863
|
if (res.status === 401) {
|
|
733
|
-
throw new Error("
|
|
864
|
+
throw new Error("Session expired. Run `ymmv login`, then `ymmv delete` again.");
|
|
734
865
|
}
|
|
735
866
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
736
867
|
if (!res.ok) {
|
|
737
|
-
throw new Error(`delete failed: ${res.status} ${await res.text()}`);
|
|
868
|
+
throw new Error(`delete failed: ${res.status} ${wireText(await res.text())}`);
|
|
738
869
|
}
|
|
739
870
|
}
|
|
740
871
|
|
|
@@ -1008,10 +1139,87 @@ function applyUnset(existing, target) {
|
|
|
1008
1139
|
};
|
|
1009
1140
|
}
|
|
1010
1141
|
|
|
1011
|
-
// src/
|
|
1012
|
-
|
|
1013
|
-
|
|
1142
|
+
// src/prompt.ts
|
|
1143
|
+
import { stdin, stdout } from "process";
|
|
1144
|
+
import { createInterface } from "readline/promises";
|
|
1145
|
+
var PromptAborted = class extends Error {
|
|
1146
|
+
constructor() {
|
|
1147
|
+
super("aborted");
|
|
1148
|
+
this.name = "PromptAborted";
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
function promptLine(label, def, color = false) {
|
|
1152
|
+
const c = palette(color);
|
|
1153
|
+
const clean = def ? sanitizeValue(def) : def;
|
|
1154
|
+
return ` ${c.faint}${label}${c.reset}${clean ? ` [${clean}]` : ""}: `;
|
|
1155
|
+
}
|
|
1156
|
+
function matchChoice(answer, keys, def) {
|
|
1157
|
+
if (keys.some((k) => k.length !== 1) || new Set(keys).size !== keys.length) {
|
|
1158
|
+
throw new Error(`choice keys must be unique single letters: ${keys.join(",")}`);
|
|
1159
|
+
}
|
|
1160
|
+
const a = answer.trim().toLowerCase();
|
|
1161
|
+
if (a === "") return def;
|
|
1162
|
+
const first = a[0];
|
|
1163
|
+
return keys.includes(first) ? first : null;
|
|
1014
1164
|
}
|
|
1165
|
+
function makePrompter() {
|
|
1166
|
+
let rl = null;
|
|
1167
|
+
const color = colorEnabled();
|
|
1168
|
+
const c = palette(color);
|
|
1169
|
+
let ac = null;
|
|
1170
|
+
const io = () => {
|
|
1171
|
+
if (!rl) {
|
|
1172
|
+
rl = createInterface({ input: stdin, output: stdout });
|
|
1173
|
+
rl.on("SIGINT", () => {
|
|
1174
|
+
if (ac) ac.abort();
|
|
1175
|
+
else {
|
|
1176
|
+
stdout.write("\n");
|
|
1177
|
+
process.exit(130);
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
rl.on("close", () => {
|
|
1181
|
+
ac?.abort();
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
return rl;
|
|
1185
|
+
};
|
|
1186
|
+
const question = async (query) => {
|
|
1187
|
+
const controller = new AbortController();
|
|
1188
|
+
ac = controller;
|
|
1189
|
+
try {
|
|
1190
|
+
return await io().question(query, { signal: controller.signal });
|
|
1191
|
+
} catch (e) {
|
|
1192
|
+
if (e instanceof Error && e.name === "AbortError") throw new PromptAborted();
|
|
1193
|
+
throw e;
|
|
1194
|
+
} finally {
|
|
1195
|
+
ac = null;
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
return {
|
|
1199
|
+
async ask(label, def) {
|
|
1200
|
+
const clean = def ? sanitizeValue(def) : def;
|
|
1201
|
+
const answer = (await question(promptLine(label, def, color))).trim();
|
|
1202
|
+
return answer === "" ? clean ?? "" : answer;
|
|
1203
|
+
},
|
|
1204
|
+
async confirm(q, defYes) {
|
|
1205
|
+
const answer = (await question(` ${q} ${c.faint}[${defYes ? "Y/n" : "y/N"}]${c.reset} `)).trim().toLowerCase();
|
|
1206
|
+
if (answer === "") return defYes;
|
|
1207
|
+
return answer === "y" || answer === "yes";
|
|
1208
|
+
},
|
|
1209
|
+
async choice(q, keys, def, hint) {
|
|
1210
|
+
for (; ; ) {
|
|
1211
|
+
const hit = matchChoice(await question(` ${q} ${c.faint}[${hint}]${c.reset} `), keys, def);
|
|
1212
|
+
if (hit !== null) return hit;
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
close() {
|
|
1216
|
+
rl?.close();
|
|
1217
|
+
rl = null;
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/commands.ts
|
|
1015
1223
|
function requireHandle(cred) {
|
|
1016
1224
|
if (cred.handle) return cred.handle;
|
|
1017
1225
|
console.error(
|
|
@@ -1023,7 +1231,7 @@ function requireHandle(cred) {
|
|
|
1023
1231
|
function assertHandleUnchanged(existing, handle) {
|
|
1024
1232
|
if (existing && existing.handle.toLowerCase() !== handle.toLowerCase()) {
|
|
1025
1233
|
throw new Error(
|
|
1026
|
-
`
|
|
1234
|
+
`This login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}". Run \`ymmv login\` to refresh, then retry.`
|
|
1027
1235
|
);
|
|
1028
1236
|
}
|
|
1029
1237
|
}
|
|
@@ -1036,14 +1244,25 @@ function newProfile(handle, entries, extras) {
|
|
|
1036
1244
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1037
1245
|
};
|
|
1038
1246
|
}
|
|
1247
|
+
function printPublished(res, color) {
|
|
1248
|
+
console.log(`Published ${res.handle} \u2192 ${link(res.url, color)}`);
|
|
1249
|
+
}
|
|
1250
|
+
function pagePointer(handle) {
|
|
1251
|
+
const color = colorEnabled();
|
|
1252
|
+
const c = palette(color);
|
|
1253
|
+
return ` ${c.faint}\u2192 ${color ? displayUrl(BASE) : BASE}/${handle}${c.reset}`;
|
|
1254
|
+
}
|
|
1039
1255
|
async function promptEntries(defaults, prompter) {
|
|
1256
|
+
const c = palette(colorEnabled());
|
|
1257
|
+
console.log(`
|
|
1258
|
+
${c.faint}Enter to keep, "-" to clear${c.reset}`);
|
|
1040
1259
|
const chosen = /* @__PURE__ */ new Map();
|
|
1041
1260
|
for (const key of CURATED_KEYS) {
|
|
1042
1261
|
const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
|
|
1043
1262
|
const value = answer === "-" ? "" : answer;
|
|
1044
1263
|
if (value) chosen.set(key, value);
|
|
1045
1264
|
}
|
|
1046
|
-
return
|
|
1265
|
+
return chosen;
|
|
1047
1266
|
}
|
|
1048
1267
|
async function publish(io) {
|
|
1049
1268
|
if (!io.interactive && !io.yes) {
|
|
@@ -1065,44 +1284,73 @@ async function publish(io) {
|
|
|
1065
1284
|
assertHandleUnchanged(existing, handle);
|
|
1066
1285
|
const defaults = buildDefaults(existing, detected);
|
|
1067
1286
|
const carried = unknownEntries(existing);
|
|
1068
|
-
let entries = entriesFromMap(defaults);
|
|
1069
1287
|
const extras = existing?.extras ?? [];
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1288
|
+
const color = colorEnabled();
|
|
1289
|
+
const site = displayUrl(BASE);
|
|
1290
|
+
const showCard = (entries) => {
|
|
1291
|
+
console.log(
|
|
1292
|
+
renderProfile(newProfile(handle, entries, extras), { color, site, mode: "preview" })
|
|
1293
|
+
);
|
|
1294
|
+
if (carried.length > 0) {
|
|
1295
|
+
const s = carried.length === 1 ? "" : "s";
|
|
1296
|
+
console.log(`(+${carried.length} newer field${s} kept as-is; upgrade ymmv-cli to edit them)`);
|
|
1297
|
+
for (const e of carried) {
|
|
1298
|
+
console.log(` ${sanitizeValue(e.key)} = ${sanitizeValue(e.value)}`);
|
|
1299
|
+
}
|
|
1081
1300
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1301
|
+
const publishedLabels = new Set(
|
|
1302
|
+
entries.filter((e) => isCuratedKey(e.key)).map((e) => KEY_LABELS[e.key].toLowerCase())
|
|
1303
|
+
);
|
|
1304
|
+
for (const x of extras) {
|
|
1305
|
+
if (publishedLabels.has(x.label.trim().toLowerCase())) {
|
|
1306
|
+
const label = sanitizeValue(x.label.trim());
|
|
1307
|
+
console.log(`(extra "${label}" duplicates a curated field; ymmv unset --extra "${label}")`);
|
|
1308
|
+
}
|
|
1090
1309
|
}
|
|
1310
|
+
};
|
|
1311
|
+
let values = defaults;
|
|
1312
|
+
const assemble = () => [...entriesFromMap(values), ...carried];
|
|
1313
|
+
if (!io.interactive || !io.prompter || io.yes) {
|
|
1314
|
+
const entries = assemble();
|
|
1315
|
+
showCard(entries);
|
|
1316
|
+
printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
|
|
1317
|
+
return;
|
|
1091
1318
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1319
|
+
try {
|
|
1320
|
+
if (!existing) values = await promptEntries(values, io.prompter);
|
|
1321
|
+
for (; ; ) {
|
|
1322
|
+
const entries = assemble();
|
|
1323
|
+
showCard(entries);
|
|
1324
|
+
const ans = await io.prompter.choice(
|
|
1325
|
+
`Publish to ${site}/${sanitizeValue(handle)}?`,
|
|
1326
|
+
["y", "n", "e"],
|
|
1327
|
+
"y",
|
|
1328
|
+
"Y/n/e=edit"
|
|
1329
|
+
);
|
|
1330
|
+
if (ans === "y") {
|
|
1331
|
+
printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
if (ans === "n") {
|
|
1335
|
+
console.log("Aborted. Nothing published.");
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
values = await promptEntries(values, io.prompter);
|
|
1339
|
+
}
|
|
1340
|
+
} catch (e) {
|
|
1341
|
+
if (e instanceof PromptAborted) {
|
|
1342
|
+
console.log("\nAborted. Nothing published.");
|
|
1343
|
+
process.exitCode = 130;
|
|
1096
1344
|
return;
|
|
1097
1345
|
}
|
|
1346
|
+
throw e;
|
|
1098
1347
|
}
|
|
1099
|
-
await publishProfile(profile);
|
|
1100
1348
|
}
|
|
1101
1349
|
async function view(handle) {
|
|
1102
1350
|
const theirs = await fetchProfileJson(handle);
|
|
1103
1351
|
const c = colorEnabled();
|
|
1104
1352
|
if (!theirs) {
|
|
1105
|
-
console.log(notFound(handle));
|
|
1353
|
+
console.log(notFound(handle, c, BASE));
|
|
1106
1354
|
return;
|
|
1107
1355
|
}
|
|
1108
1356
|
const cred = await loadToken();
|
|
@@ -1115,12 +1363,12 @@ async function view(handle) {
|
|
|
1115
1363
|
return;
|
|
1116
1364
|
}
|
|
1117
1365
|
if (!mine) {
|
|
1118
|
-
console.log(renderProfile(theirs, { color: c }));
|
|
1366
|
+
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
1119
1367
|
console.log(nudge(c));
|
|
1120
1368
|
return;
|
|
1121
1369
|
}
|
|
1122
1370
|
}
|
|
1123
|
-
console.log(renderProfile(theirs, { color: c }));
|
|
1371
|
+
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
1124
1372
|
}
|
|
1125
1373
|
async function runSet(target) {
|
|
1126
1374
|
const cred = await ensureLogin();
|
|
@@ -1129,12 +1377,9 @@ async function runSet(target) {
|
|
|
1129
1377
|
const existing = await fetchProfileJson(handle);
|
|
1130
1378
|
assertHandleUnchanged(existing, handle);
|
|
1131
1379
|
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
|
-
}
|
|
1380
|
+
const res = await publishProfile(newProfile(handle, entries, extras));
|
|
1381
|
+
const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
|
|
1382
|
+
console.log(`${line}${pagePointer(res.handle)}`);
|
|
1138
1383
|
}
|
|
1139
1384
|
async function runUnset(target) {
|
|
1140
1385
|
const cred = await ensureLogin();
|
|
@@ -1143,7 +1388,7 @@ async function runUnset(target) {
|
|
|
1143
1388
|
const existing = await fetchProfileJson(handle);
|
|
1144
1389
|
assertHandleUnchanged(existing, handle);
|
|
1145
1390
|
if (!existing) {
|
|
1146
|
-
console.log("No profile yet
|
|
1391
|
+
console.log("No profile yet. Run `ymmv` to publish one.");
|
|
1147
1392
|
return;
|
|
1148
1393
|
}
|
|
1149
1394
|
const { entries, extras, removed } = applyUnset(existing, target);
|
|
@@ -1153,18 +1398,13 @@ async function runUnset(target) {
|
|
|
1153
1398
|
);
|
|
1154
1399
|
return;
|
|
1155
1400
|
}
|
|
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
|
-
}
|
|
1401
|
+
const res = await publishProfile(newProfile(handle, entries, extras));
|
|
1402
|
+
const line = target.kind === "curated" ? `Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").` : `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`;
|
|
1403
|
+
console.log(`${line}${pagePointer(res.handle)}`);
|
|
1164
1404
|
}
|
|
1165
1405
|
async function runDelete(io) {
|
|
1166
1406
|
const cred = await ensureLogin();
|
|
1167
|
-
const target = cred.handle ?
|
|
1407
|
+
const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
|
|
1168
1408
|
if (!io.yes) {
|
|
1169
1409
|
if (!io.interactive || !io.prompter) {
|
|
1170
1410
|
console.error(
|
|
@@ -1173,9 +1413,19 @@ async function runDelete(io) {
|
|
|
1173
1413
|
process.exitCode = 1;
|
|
1174
1414
|
return;
|
|
1175
1415
|
}
|
|
1176
|
-
|
|
1416
|
+
let go;
|
|
1417
|
+
try {
|
|
1418
|
+
go = await io.prompter.confirm(`Delete ${target}? This is permanent`, false);
|
|
1419
|
+
} catch (e) {
|
|
1420
|
+
if (e instanceof PromptAborted) {
|
|
1421
|
+
console.log("\nCancelled. Nothing deleted.");
|
|
1422
|
+
process.exitCode = 130;
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
throw e;
|
|
1426
|
+
}
|
|
1177
1427
|
if (!go) {
|
|
1178
|
-
console.log("Cancelled
|
|
1428
|
+
console.log("Cancelled. Nothing deleted.");
|
|
1179
1429
|
return;
|
|
1180
1430
|
}
|
|
1181
1431
|
}
|
|
@@ -1184,37 +1434,6 @@ async function runDelete(io) {
|
|
|
1184
1434
|
console.log(`Deleted ${target}. Run \`ymmv\` to publish again.`);
|
|
1185
1435
|
}
|
|
1186
1436
|
|
|
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
|
-
};
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
1437
|
// src/resolve.ts
|
|
1219
1438
|
var SET_EXTRA = 'ymmv set --extra "Label=Value"';
|
|
1220
1439
|
var UNSET_EXTRA = 'ymmv unset --extra "Label"';
|
|
@@ -1288,7 +1507,7 @@ function resolveArg(argv) {
|
|
|
1288
1507
|
return { kind: "view", handle };
|
|
1289
1508
|
}
|
|
1290
1509
|
if (first.startsWith("-")) {
|
|
1291
|
-
return { kind: "error", message: `
|
|
1510
|
+
return { kind: "error", message: `Unknown option "${first}". Run \`ymmv help\`.` };
|
|
1292
1511
|
}
|
|
1293
1512
|
if (!isValidHandle(first)) {
|
|
1294
1513
|
return {
|
|
@@ -1300,12 +1519,12 @@ function resolveArg(argv) {
|
|
|
1300
1519
|
}
|
|
1301
1520
|
|
|
1302
1521
|
// src/index.ts
|
|
1303
|
-
var
|
|
1522
|
+
var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
|
|
1304
1523
|
|
|
1305
|
-
Usage
|
|
1524
|
+
${c.faint}Usage:${c.reset}
|
|
1306
1525
|
ymmv detect your stack, confirm, and publish your profile
|
|
1307
1526
|
ymmv -y publish without prompts (required when stdin isn't a TTY)
|
|
1308
|
-
ymmv <handle> view a profile
|
|
1527
|
+
ymmv <handle> view a profile; logged in, see the diff vs yours
|
|
1309
1528
|
ymmv view <handle> explicit view (when a handle collides with a verb)
|
|
1310
1529
|
ymmv set <key> <value> set one curated key
|
|
1311
1530
|
ymmv set --extra "L=V" set a free-form extra
|
|
@@ -1315,17 +1534,14 @@ Usage:
|
|
|
1315
1534
|
ymmv login | logout GitHub device-flow auth
|
|
1316
1535
|
ymmv help | --version
|
|
1317
1536
|
|
|
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`;
|
|
1537
|
+
${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser, window-manager,
|
|
1538
|
+
font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
|
|
1323
1539
|
async function logout() {
|
|
1324
1540
|
const stored = await loadToken();
|
|
1325
1541
|
if (!stored) {
|
|
1326
1542
|
const otherBase = await peekBase();
|
|
1327
1543
|
console.log(
|
|
1328
|
-
otherBase && otherBase !== BASE ? `Not logged in to ${BASE} (a token for ${otherBase} exists
|
|
1544
|
+
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."
|
|
1329
1545
|
);
|
|
1330
1546
|
return;
|
|
1331
1547
|
}
|
|
@@ -1334,7 +1550,7 @@ async function logout() {
|
|
|
1334
1550
|
revoked = await revokeYmmvToken(stored.token);
|
|
1335
1551
|
} catch {
|
|
1336
1552
|
console.error(
|
|
1337
|
-
"Couldn't reach the server to revoke
|
|
1553
|
+
"Couldn't reach the server to revoke. Your token is still active. Run `ymmv logout` again when connected."
|
|
1338
1554
|
);
|
|
1339
1555
|
process.exitCode = 1;
|
|
1340
1556
|
return;
|
|
@@ -1377,14 +1593,17 @@ async function main(argv) {
|
|
|
1377
1593
|
case "delete":
|
|
1378
1594
|
await interactive(runDelete, cmd.yes);
|
|
1379
1595
|
break;
|
|
1380
|
-
case "login":
|
|
1596
|
+
case "login": {
|
|
1381
1597
|
await login();
|
|
1598
|
+
const c = palette(colorEnabled());
|
|
1599
|
+
console.log(` ${c.faint}next: run ymmv to publish your stack${c.reset}`);
|
|
1382
1600
|
break;
|
|
1601
|
+
}
|
|
1383
1602
|
case "logout":
|
|
1384
1603
|
await logout();
|
|
1385
1604
|
break;
|
|
1386
1605
|
case "help":
|
|
1387
|
-
console.log(
|
|
1606
|
+
console.log(help(palette(colorEnabled())));
|
|
1388
1607
|
break;
|
|
1389
1608
|
case "version":
|
|
1390
1609
|
printVersion();
|