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.
Files changed (3) hide show
  1. package/README.md +7 -2
  2. package/dist/cli.js +536 -317
  3. 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
- bardisty you
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 · 1 shared — your mileage may vary
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 doFetch(DEVICE_CODE_URL, {
444
- method: "POST",
445
- headers: { accept: "application/json" },
446
- body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID })
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
- return await res.json();
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
- const res = await doFetch(TOKEN_URL, {
464
- method: "POST",
465
- headers: { accept: "application/json" },
466
- body: new URLSearchParams({
467
- client_id: GITHUB_CLIENT_ID,
468
- device_code: dc.device_code,
469
- grant_type: "urn:ietf:params:oauth:grant-type:device_code"
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.ok) {
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
- "GitHub isn't responding to the login poll \u2014 check your connection and run `ymmv login` again."
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 \u2014 run `ymmv login` again.");
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 \u2014 run `ymmv login` again.");
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 \u2014 run `ymmv login` in a real terminal (a piped or CI shell can't complete the GitHub device flow)."
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
- console.log(`
514
- Open ${dc.verification_uri} and enter code: ${dc.user_code}
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 \u2014 no handle bound (your GitHub username is a reserved word).\n"
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 \u2014 too many requests";
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("login did not persist a token \u2014 run `ymmv login`.");
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) => fetch(`${BASE}/api/v1/profile`, {
682
- method: "POST",
683
- headers: { "content-type": "application/json", authorization: `Bearer ${c.token}` },
684
- // Send the login-bound handle, never a caller-guessed one — the official client never claims
685
- // a handle it doesn't own.
686
- body: JSON.stringify({ ...profile, handle: c.handle ?? profile.handle }),
687
- redirect: "manual"
688
- // a mutation must never follow a redirect into a false success
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
- "the stored login changed while this command was running \u2014 re-run it under the current account."
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
- if (res.status === 401) await deleteToken();
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("authentication failed \u2014 run `ymmv login`.");
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
- console.log(`Published ${shown} -> ${BASE}/${shown}`);
842
+ return { handle: shown, url: `${BASE}/${shown}` };
716
843
  }
717
844
  async function fetchProfileJson(handle) {
718
- const res = await fetch(`${BASE}/api/v1/u/${encodeURIComponent(handle)}`);
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 fetch(`${BASE}/api/v1/profile`, {
728
- method: "DELETE",
729
- headers: { authorization: `Bearer ${cred.token}` },
730
- redirect: "manual"
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("session expired \u2014 run `ymmv login`, then `ymmv delete` again.");
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/commands.ts
1012
- function colorEnabled() {
1013
- return useColor(process.env, Boolean(process.stdout.isTTY));
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
- `this login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}" \u2014 run \`ymmv login\` to refresh, then retry.`
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 entriesFromMap(chosen);
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
- if (io.interactive && io.prompter) {
1071
- entries = await promptEntries(defaults, io.prompter);
1072
- }
1073
- entries = [...entries, ...carried];
1074
- const profile = newProfile(handle, entries, extras);
1075
- console.log(renderProfile(profile, { color: colorEnabled() }));
1076
- if (carried.length > 0) {
1077
- const s = carried.length === 1 ? "" : "s";
1078
- console.log(`(+${carried.length} newer field${s} kept as-is \u2014 upgrade ymmv-cli to edit them)`);
1079
- for (const e of carried) {
1080
- console.log(` ${sanitizeValue(e.key)} = ${sanitizeValue(e.value)}`);
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
- const publishedLabels = new Set(
1084
- entries.filter((e) => isCuratedKey(e.key)).map((e) => KEY_LABELS[e.key].toLowerCase())
1085
- );
1086
- for (const x of extras) {
1087
- if (publishedLabels.has(x.label.trim().toLowerCase())) {
1088
- const label = sanitizeValue(x.label.trim());
1089
- console.log(`(extra "${label}" duplicates a curated field \u2014 ymmv unset --extra "${label}")`);
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
- if (io.interactive && io.prompter && !io.yes) {
1093
- const go = await io.prompter.confirm(`Publish to ymmv.fyi/${handle}?`, true);
1094
- if (!go) {
1095
- console.log("Aborted \u2014 nothing published.");
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
- if (target.kind === "curated") {
1134
- console.log(`Set ${KEY_LABELS[target.key]} = ${target.value}.`);
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 \u2014 run `ymmv` to publish one.");
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
- if (target.kind === "curated") {
1158
- console.log(`Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").`);
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 ? `ymmv.fyi/${cred.handle}` : "your profile";
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
- const go = await io.prompter.confirm(`Delete ${target}? This is permanent`, false);
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 \u2014 nothing deleted.");
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: `unknown option "${first}". Run \`ymmv help\`.` };
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 HELP = `ymmv \u2014 terminal-native developer tool-stack profiles (ymmv.fyi)
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 \u2014 logged in, see the diff vs yours
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: editor, os, shell, prompt, terminal, browser, window-manager,
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 \u2014 set YMMV_API to that to log out of it).` : "Not logged in."
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 \u2014 your token is still active. Run `ymmv logout` again when connected."
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(HELP);
1606
+ console.log(help(palette(colorEnabled())));
1388
1607
  break;
1389
1608
  case "version":
1390
1609
  printVersion();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {