ymmv-cli 0.5.0 → 0.6.1

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