ymmv-cli 0.6.2 → 0.8.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 +45 -8
  2. package/dist/cli.js +450 -108
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -40,18 +40,55 @@ via npm Trusted Publishing, with provenance.
40
40
 
41
41
  ## Commands
42
42
 
43
- - `ymmv` detects, confirms, and publishes (re-run any time to update)
43
+ - `ymmv` detects, confirms, and publishes (re-run any time to update; `ymmv publish` is the same command)
44
44
  - `ymmv <handle>` views a profile, or diffs it against yours when you're logged in
45
45
  - `ymmv set editor Neovim` changes one value
46
- - `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own
46
+ - `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own (`-e` works too)
47
47
  - `ymmv unset editor` removes one value (`ymmv set editor -` works too); `ymmv unset --extra "Keyboard"` removes an extra
48
- - `ymmv delete` removes your profile
48
+ - `ymmv delete` removes your profile (`ymmv delete -y` skips the confirm, for scripts)
49
49
  - `ymmv login` / `ymmv logout` sign in / out
50
-
51
- Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`.
52
-
53
- Color output respects `NO_COLOR`; set `YMMV_API` to point the CLI at a different Worker
54
- (development).
50
+ - `ymmv version` prints the CLI version
51
+
52
+ Values are capped at 256 characters and extra labels at 64; a profile holds up
53
+ to 32 extras.
54
+
55
+ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full contract
56
+ (shape, statuses, caching, CORS):
57
+ <https://github.com/ymmv-fyi/ymmv/blob/main/docs/api.md>.
58
+
59
+ ## Environment variables
60
+
61
+ - `NO_COLOR` disables color output (and `FORCE_COLOR=0`/`false` force-disables it).
62
+ - `YMMV_API` points the CLI at a different Worker (development/staging). Bare origin only.
63
+ - `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
64
+ the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
65
+ `ymmv login` / `ymmv logout` keep acting on the stored login. Viewing (`ymmv <handle>`) also
66
+ keeps using the stored login for the you-side of a diff. The token is sent to the server
67
+ `YMMV_API` selects, so set the two together.
68
+ - `YMMV_HANDLE` names the GitHub username `YMMV_TOKEN` belongs to. Required for `ymmv -y` and
69
+ `ymmv set`/`unset` under an env token (there is no server lookup for it); ignored without
70
+ `YMMV_TOKEN`.
71
+
72
+ ## Publishing from CI
73
+
74
+ `ymmv login` needs a browser, so mint the token on your machine and hand it to CI:
75
+
76
+ 1. Run `ymmv login` locally.
77
+ 2. Copy the `token` value from the token file:
78
+ `~/.config/ymmv/token.json` (Linux), `~/Library/Preferences/ymmv/token.json` (macOS),
79
+ `%APPDATA%\ymmv\Config\token.json` (Windows).
80
+ 3. Set it as a CI secret named `YMMV_TOKEN`, and set `YMMV_HANDLE` to your GitHub username.
81
+ 4. Run `npx ymmv-cli -y` in the job.
82
+
83
+ Two things to know:
84
+
85
+ - `ymmv -y` publishes the merge of your existing profile with what it detects on the machine it
86
+ runs on. Values you already published always win, but curated keys you have never set get the
87
+ CI runner's detected values (its OS, shell, and so on). For targeted updates from CI, prefer
88
+ `ymmv set <key> <value>`.
89
+ - A rejected or revoked `YMMV_TOKEN` fails with an error naming the variable; nothing falls back
90
+ to an interactive login, and the stored login file on the runner (if any) is left untouched.
91
+ `ymmv delete` acts on the account the token is bound to, regardless of `YMMV_HANDLE`.
55
92
 
56
93
  ## License
57
94
 
package/dist/cli.js CHANGED
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/index.ts
4
- import { readFileSync as readFileSync2 } from "fs";
5
-
6
- // src/config.ts
7
- var BASE = (process.env.YMMV_API ?? "https://ymmv.fyi").replace(/\/+$/, "");
3
+ // ../shared/dist/caps.js
4
+ var MAX_EXTRAS = 32;
5
+ var MAX_LABEL = 64;
6
+ var MAX_VALUE = 256;
8
7
 
9
8
  // ../shared/dist/keys.js
10
9
  var CURATED_KEYS = [
@@ -282,7 +281,8 @@ function parseProfile(raw) {
282
281
  if (!isRecord(raw))
283
282
  throw new ProfileParseError("profile is not an object");
284
283
  if (raw.schema_version !== SCHEMA_VERSION) {
285
- throw new ProfileParseError(`unsupported schema_version: ${String(raw.schema_version)}`);
284
+ const got = String(raw.schema_version);
285
+ throw new ProfileParseError(`unsupported schema_version: ${got.length > 64 ? `${got.slice(0, 64)}\u2026` : got}. Upgrade the ymmv CLI (npm i -g ymmv-cli).`);
286
286
  }
287
287
  if (typeof raw.handle !== "string")
288
288
  throw new ProfileParseError("handle is not a string");
@@ -340,9 +340,22 @@ function parseProfile(raw) {
340
340
 
341
341
  // ../shared/dist/reserved.js
342
342
  var RESERVED_ROUTES = ["404", "api", "login", "logout"];
343
- var CLI_VERBS = ["login", "logout", "set", "unset", "delete", "view", "help"];
343
+ var CLI_VERBS = [
344
+ "login",
345
+ "logout",
346
+ "set",
347
+ "unset",
348
+ "delete",
349
+ "view",
350
+ "help",
351
+ "publish",
352
+ "version"
353
+ ];
344
354
  var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
345
355
  var RESERVED_SET = new Set(RESERVED);
356
+ function isReserved(handle) {
357
+ return RESERVED_SET.has(handle.toLowerCase());
358
+ }
346
359
  var HANDLE_RE = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$/;
347
360
  function isValidHandle(handle) {
348
361
  return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
@@ -385,7 +398,9 @@ function sanitizeValue(value) {
385
398
  }
386
399
  function useColor(env, isTTY) {
387
400
  if (env.NO_COLOR !== void 0) return false;
388
- if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
401
+ if (env.FORCE_COLOR !== void 0) {
402
+ return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
403
+ }
389
404
  if (env.TERM === "dumb") return false;
390
405
  return isTTY;
391
406
  }
@@ -445,13 +460,15 @@ function renderProfile(profile, opts) {
445
460
  const val = (v) => isHttpUrl(v) ? link(v, opts.color) : v;
446
461
  const lines = [
447
462
  "",
448
- ` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`,
449
- ""
463
+ ` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`
450
464
  ];
451
- for (const r of rows) {
452
- lines.push(
453
- r.value === null ? ` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}` : ` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}`
454
- );
465
+ if (rows.length) {
466
+ lines.push("");
467
+ for (const r of rows) {
468
+ lines.push(
469
+ r.value === null ? ` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}` : ` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}`
470
+ );
471
+ }
455
472
  }
456
473
  if (extras.length) {
457
474
  lines.push("");
@@ -526,6 +543,11 @@ function notFound(handle, color, base) {
526
543
  }
527
544
 
528
545
  // src/http.ts
546
+ var REQUEST_TIMEOUT_MS = 3e4;
547
+ var TIMEOUT_TEXT = "request timed out";
548
+ function isTimeoutError(err) {
549
+ return err instanceof Error && (err.name === "TimeoutError" || err.cause instanceof Error && err.cause.name === "TimeoutError");
550
+ }
529
551
  function causeText(err) {
530
552
  const pick = (e) => {
531
553
  if (e instanceof AggregateError) {
@@ -536,6 +558,7 @@ function causeText(err) {
536
558
  }
537
559
  return e.message;
538
560
  };
561
+ if (isTimeoutError(err)) return TIMEOUT_TEXT;
539
562
  let text;
540
563
  if (err instanceof Error) {
541
564
  const fromCause = err.cause instanceof Error ? pick(err.cause) : "";
@@ -549,17 +572,119 @@ function wireText(text) {
549
572
  const clean = sanitizeValue(String(text));
550
573
  return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
551
574
  }
575
+ var INVISIBLE_RE = new RegExp("\\p{Default_Ignorable_Code_Point}", "gu");
576
+ async function wireBody(res) {
577
+ try {
578
+ return await res.text();
579
+ } catch (err) {
580
+ if (isTimeoutError(err)) throw err;
581
+ return "";
582
+ }
583
+ }
584
+ function wireErrorBody(raw) {
585
+ try {
586
+ const body = JSON.parse(raw);
587
+ const out = {};
588
+ if (typeof body?.error === "string") out.slug = body.error;
589
+ if (typeof body?.message === "string") {
590
+ const clean = wireText(body.message).trim();
591
+ if (clean.replace(INVISIBLE_RE, "").trim()) out.message = clean;
592
+ }
593
+ return out;
594
+ } catch {
595
+ return {};
596
+ }
597
+ }
598
+ async function serverMessage(res) {
599
+ return wireErrorBody(await wireBody(res)).message;
600
+ }
601
+ function withRetryHint(msg, res) {
602
+ const retry = res.headers.get("retry-after");
603
+ return retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg;
604
+ }
605
+ function displayError(err) {
606
+ if (err instanceof Error && err.name === "TimeoutError") return TIMEOUT_TEXT;
607
+ const text = err instanceof Error ? err.message : String(err);
608
+ return text.split(/\r?\n/).map((line) => sanitizeValue(line)).join("\n");
609
+ }
610
+ var NetworkError = class extends Error {
611
+ constructor(message2, options) {
612
+ super(message2, options);
613
+ this.name = "NetworkError";
614
+ }
615
+ };
552
616
  async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
553
617
  try {
554
- return await fetchFn(url, init);
618
+ return await fetchFn(url, {
619
+ ...init,
620
+ signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
621
+ });
555
622
  } catch (err) {
556
- throw new Error(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
623
+ throw new NetworkError(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
557
624
  cause: err
558
625
  });
559
626
  }
560
627
  }
561
628
 
629
+ // src/index.ts
630
+ import { readFileSync as readFileSync2 } from "fs";
631
+
632
+ // src/config.ts
633
+ function normalizeBase(raw) {
634
+ return raw.replace(/\/+$/, "");
635
+ }
636
+ var BASE = normalizeBase(process.env.YMMV_API || "https://ymmv.fyi");
637
+ function baseProblem(raw = process.env.YMMV_API) {
638
+ if (raw === void 0 || raw === "") return null;
639
+ const shown = `YMMV_API is set to "${sanitizeValue(raw)}"`;
640
+ if (/\s/.test(raw)) return `${shown} which contains whitespace. Remove it.`;
641
+ const base = normalizeBase(raw);
642
+ let url;
643
+ try {
644
+ url = new URL(base);
645
+ } catch {
646
+ return `${shown} which is not a full URL. Use a bare origin like https://ymmv.fyi (the http:// or https:// scheme is required).`;
647
+ }
648
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
649
+ return `${shown} which is not an http or https URL. Use a bare origin like https://ymmv.fyi.`;
650
+ }
651
+ if (url.username || url.password) {
652
+ return `${shown} which contains credentials. Use a bare origin like https://ymmv.fyi.`;
653
+ }
654
+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
655
+ return `${shown} which is not a bare origin. Drop the path (the Worker's redirects are root-absolute, so a path-mounted base breaks): use just the scheme and host, like https://ymmv.fyi.`;
656
+ }
657
+ if (base !== url.origin) {
658
+ return `${shown} which is not in canonical form. Use exactly the scheme and host, like https://ymmv.fyi.`;
659
+ }
660
+ return null;
661
+ }
662
+ function credentialEnvProblem(rawToken = process.env.YMMV_TOKEN, rawHandle = process.env.YMMV_HANDLE) {
663
+ if (rawToken === void 0 || rawToken === "") return null;
664
+ if (!/^[\x21-\x7E]+$/.test(rawToken)) {
665
+ return "YMMV_TOKEN contains whitespace, control, or non-ASCII characters. Set it to the exact token value.";
666
+ }
667
+ if (rawHandle !== void 0 && rawHandle !== "") {
668
+ const shown = `YMMV_HANDLE is set to "${sanitizeValue(rawHandle)}"`;
669
+ if (!isValidHandle(rawHandle)) {
670
+ return `${shown} which is not a valid GitHub username. Set it to the handle bound to YMMV_TOKEN.`;
671
+ }
672
+ if (isReserved(rawHandle)) {
673
+ return `${shown} which is a reserved word, so no handle can be bound to it.`;
674
+ }
675
+ }
676
+ return null;
677
+ }
678
+
562
679
  // src/auth-http.ts
680
+ async function bodyJson(res) {
681
+ try {
682
+ return await res.json();
683
+ } catch (err) {
684
+ if (isTimeoutError(err)) throw err;
685
+ return null;
686
+ }
687
+ }
563
688
  async function mintYmmvToken(accessToken) {
564
689
  const res = await safeFetch(
565
690
  `${BASE}/api/v1/auth/token`,
@@ -575,20 +700,32 @@ async function mintYmmvToken(accessToken) {
575
700
  BASE
576
701
  );
577
702
  if (!res.ok) {
578
- const body = await res.json().catch(() => ({}));
579
703
  if (res.status === 503) {
580
704
  throw new Error(
581
- body.message ? wireText(body.message) : "GitHub is unavailable. Run `ymmv login` again shortly."
705
+ await serverMessage(res) ?? "GitHub is unavailable. Run `ymmv login` again shortly."
582
706
  );
583
707
  }
584
708
  if (res.status === 429) {
585
- const retry = res.headers.get("retry-after");
586
- const msg = body.message ? wireText(body.message) : "Too many login attempts. Slow down and try again shortly";
587
- throw new Error(retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg);
709
+ throw new Error(
710
+ withRetryHint(
711
+ await serverMessage(res) ?? "Too many login attempts. Slow down and try again shortly",
712
+ res
713
+ )
714
+ );
588
715
  }
589
- throw new Error(`login failed: ${res.status} ${wireText(body.error ?? "")}`.trim());
716
+ const body = await bodyJson(res) ?? {};
717
+ const slug = typeof body.error === "string" ? body.error : "";
718
+ if (res.status === 401 && slug === "github_auth_failed") {
719
+ throw new Error("GitHub rejected the authorization. Run `ymmv login` to try again.");
720
+ }
721
+ if (res.status === 500 && slug === "internal_error") {
722
+ throw new Error(
723
+ "The server hit an error minting your login. Run `ymmv login` again shortly."
724
+ );
725
+ }
726
+ throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
590
727
  }
591
- const data = await res.json().catch(() => null);
728
+ const data = await bodyJson(res);
592
729
  if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
593
730
  throw new Error(
594
731
  `Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`
@@ -597,16 +734,23 @@ async function mintYmmvToken(accessToken) {
597
734
  return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
598
735
  }
599
736
  async function revokeYmmvToken(token) {
600
- const res = await fetch(`${BASE}/api/v1/auth/logout`, {
601
- method: "POST",
602
- headers: { authorization: `Bearer ${token}` },
603
- // Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete the
604
- // local file while the server token stays live). Same guard as mint + publish/delete.
605
- redirect: "manual"
606
- });
737
+ const res = await safeFetch(
738
+ `${BASE}/api/v1/auth/logout`,
739
+ {
740
+ method: "POST",
741
+ headers: { authorization: `Bearer ${token}` },
742
+ // Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete
743
+ // the local file while the server token stays live). Same guard as mint + publish/delete.
744
+ redirect: "manual"
745
+ },
746
+ BASE
747
+ );
607
748
  if (!res.ok) throw new Error(`logout failed: ${res.status}`);
608
- const body = await res.json().catch(() => ({}));
609
- return body.revoked === true;
749
+ const body = await bodyJson(res);
750
+ if (!body || typeof body.revoked !== "boolean") {
751
+ throw new Error("logout failed: unexpected response");
752
+ }
753
+ return body.revoked;
610
754
  }
611
755
 
612
756
  // src/commands.ts
@@ -638,38 +782,49 @@ async function saveToken(data) {
638
782
  throw e;
639
783
  }
640
784
  }
641
- async function loadToken() {
642
- let raw;
785
+ async function readTokenFile() {
643
786
  try {
644
- raw = await readFile(tokenFilePath(), "utf8");
787
+ const parsed = JSON.parse(await readFile(tokenFilePath(), "utf8"));
788
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
645
789
  } catch {
646
790
  return null;
647
791
  }
648
- let parsed;
649
- try {
650
- parsed = JSON.parse(raw);
651
- } catch {
792
+ }
793
+ async function loadToken() {
794
+ const parsed = await readTokenFile();
795
+ if (!parsed || parsed.base !== BASE || typeof parsed.token !== "string" || parsed.token === "" || parsed.handle !== null && typeof parsed.handle !== "string") {
652
796
  return null;
653
797
  }
654
- if (parsed.base !== BASE || typeof parsed.token !== "string") return null;
655
798
  return parsed;
656
799
  }
800
+ async function peekCredential() {
801
+ const parsed = await readTokenFile();
802
+ return parsed && typeof parsed.base === "string" && typeof parsed.token === "string" && parsed.token !== "" ? { base: parsed.base, token: parsed.token } : null;
803
+ }
804
+ async function loadCredential() {
805
+ const envToken = process.env.YMMV_TOKEN || "";
806
+ if (envToken !== "") {
807
+ return { base: BASE, token: envToken, handle: process.env.YMMV_HANDLE || null, source: "env" };
808
+ }
809
+ const stored = await loadToken();
810
+ return stored ? { ...stored, source: "file" } : null;
811
+ }
657
812
  async function deleteToken() {
658
813
  await rm(tokenFilePath(), { force: true });
659
814
  }
660
815
  async function peekBase() {
661
- try {
662
- const parsed = JSON.parse(await readFile(tokenFilePath(), "utf8"));
663
- return typeof parsed.base === "string" ? parsed.base : null;
664
- } catch {
665
- return null;
666
- }
816
+ const parsed = await readTokenFile();
817
+ return typeof parsed?.base === "string" ? parsed.base : null;
667
818
  }
668
819
 
669
820
  // src/device-flow.ts
670
821
  var DEVICE_CODE_URL = "https://github.com/login/device/code";
671
822
  var TOKEN_URL = "https://github.com/login/oauth/access_token";
672
823
  var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
824
+ var DEFAULT_POLL_INTERVAL_S = 5;
825
+ function usableInterval(v) {
826
+ return typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 900;
827
+ }
673
828
  async function requestDeviceCode(deps = {}) {
674
829
  const doFetch = deps.fetch ?? globalThis.fetch;
675
830
  const res = await safeFetch(
@@ -685,8 +840,14 @@ async function requestDeviceCode(deps = {}) {
685
840
  if (!res.ok) {
686
841
  throw new Error(`device code request failed: ${res.status} ${wireText(await res.text())}`);
687
842
  }
688
- const data = await res.json().catch(() => null);
689
- if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.expires_in !== "number") {
843
+ const data = await res.json().catch((err) => {
844
+ if (isTimeoutError(err)) throw err;
845
+ return null;
846
+ });
847
+ if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || // expires_in gets the same rigor as interval: NaN makes the deadline NaN (instant false
848
+ // "expired"), Infinity/1e300 make it unreachable (a middlebox feeding parseable
849
+ // authorization_pending bodies would hold the login forever - the deadline is the only exit).
850
+ typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || data.expires_in > 86400 || data.interval !== void 0 && !usableInterval(data.interval)) {
690
851
  throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
691
852
  }
692
853
  return data;
@@ -695,13 +856,14 @@ async function pollForToken(dc, deps = {}) {
695
856
  const doFetch = deps.fetch ?? globalThis.fetch;
696
857
  const sleep = deps.sleep ?? realSleep;
697
858
  const now = deps.now ?? Date.now;
698
- let interval = dc.interval || 5;
859
+ let interval = usableInterval(dc.interval) ? dc.interval : DEFAULT_POLL_INTERVAL_S;
699
860
  const deadline = now() + dc.expires_in * 1e3;
700
861
  let transientFailures = 0;
701
862
  let lastCause = "";
702
863
  const MAX_TRANSIENT_FAILURES = 5;
703
864
  while (now() < deadline) {
704
865
  await sleep(interval * 1e3);
866
+ if (now() >= deadline) break;
705
867
  let res;
706
868
  try {
707
869
  res = await doFetch(TOKEN_URL, {
@@ -711,7 +873,12 @@ async function pollForToken(dc, deps = {}) {
711
873
  client_id: GITHUB_CLIENT_ID,
712
874
  device_code: dc.device_code,
713
875
  grant_type: "urn:ietf:params:oauth:grant-type:device_code"
714
- })
876
+ }),
877
+ // GitHub answers this endpoint immediately — slow_down pacing lives in the sleep above,
878
+ // never inside a held request — so the per-request timeout can't cut pacing short. It only
879
+ // turns a HUNG poll into a TimeoutError feeding the transient counter (previously a hang
880
+ // here stalled the login forever: the deadline is only checked between iterations).
881
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
715
882
  });
716
883
  } catch (err) {
717
884
  lastCause = causeText(err);
@@ -720,9 +887,9 @@ async function pollForToken(dc, deps = {}) {
720
887
  if (res?.ok) {
721
888
  try {
722
889
  tok = await res.json();
723
- } catch {
890
+ } catch (err) {
724
891
  tok = void 0;
725
- lastCause = "unexpected response body";
892
+ lastCause = isTimeoutError(err) ? causeText(err) : "unexpected response body";
726
893
  }
727
894
  } else if (res) {
728
895
  lastCause = `HTTP ${res.status}`;
@@ -741,7 +908,7 @@ async function pollForToken(dc, deps = {}) {
741
908
  case "authorization_pending":
742
909
  break;
743
910
  case "slow_down":
744
- interval = Math.max(tok.interval ?? 0, interval + 5);
911
+ interval = usableInterval(tok.interval) ? Math.max(tok.interval, interval + DEFAULT_POLL_INTERVAL_S) : interval + DEFAULT_POLL_INTERVAL_S;
745
912
  break;
746
913
  case "access_denied":
747
914
  throw new Error("Authorization denied. Run `ymmv login` to try again.");
@@ -759,9 +926,24 @@ async function login(deps = {}) {
759
926
  "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)."
760
927
  );
761
928
  }
762
- const dc = await requestDeviceCode(deps);
929
+ if (process.env.YMMV_TOKEN) {
930
+ console.error(
931
+ message(
932
+ "YMMV_TOKEN is set and takes precedence over stored logins. This login will be saved but not used until you unset it."
933
+ )
934
+ );
935
+ }
936
+ const prior = await peekCredential();
763
937
  const color = colorEnabled();
764
938
  const c = palette(color);
939
+ if (prior && prior.base !== BASE) {
940
+ console.error(
941
+ message(
942
+ `You're logged in to ${sanitizeValue(prior.base)}. Logging in here replaces that stored token. To revoke it first, set YMMV_API to that server and run \`ymmv logout\`.`
943
+ )
944
+ );
945
+ }
946
+ const dc = await requestDeviceCode(deps);
765
947
  const verifyUri = /^https:\/\/github\.com\//.test(dc.verification_uri) ? link(dc.verification_uri, color) : sanitizeValue(dc.verification_uri);
766
948
  console.log(
767
949
  message(
@@ -771,6 +953,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
771
953
  );
772
954
  const accessToken = await pollForToken(dc, deps);
773
955
  const { token, handle } = await mintYmmvToken(accessToken);
956
+ const replaced = await peekCredential();
774
957
  try {
775
958
  await saveToken({ token, handle });
776
959
  } catch (e) {
@@ -778,6 +961,13 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
778
961
  });
779
962
  throw e;
780
963
  }
964
+ if (replaced && replaced.base === BASE && replaced.token !== token) {
965
+ try {
966
+ await revokeYmmvToken(replaced.token);
967
+ } catch {
968
+ console.error(message(`${c.faint}(couldn't revoke the previous session's token)${c.reset}`));
969
+ }
970
+ }
781
971
  console.log(
782
972
  message(
783
973
  handle ? `Logged in as ${handle}.` : "Logged in. No handle bound (your GitHub username is a reserved word)."
@@ -786,21 +976,21 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
786
976
  }
787
977
 
788
978
  // src/api.ts
789
- async function rateLimitMessage(res) {
790
- const retry = res.headers.get("retry-after");
791
- let msg = "rate limited, too many requests";
792
- try {
793
- const body = await res.json();
794
- if (typeof body?.message === "string" && body.message) msg = wireText(body.message);
795
- } catch {
979
+ var PublishRefusal = class extends Error {
980
+ constructor(msg) {
981
+ super(msg);
982
+ this.name = "PublishRefusal";
796
983
  }
797
- return retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg;
984
+ };
985
+ var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (revoked or expired).";
986
+ async function rateLimitMessage(res) {
987
+ return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
798
988
  }
799
989
  async function ensureLogin() {
800
- const existing = await loadToken();
990
+ const existing = await loadCredential();
801
991
  if (existing) return existing;
802
992
  await login();
803
- const fresh = await loadToken();
993
+ const fresh = await loadCredential();
804
994
  if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
805
995
  return fresh;
806
996
  }
@@ -820,35 +1010,60 @@ async function publishProfile(profile) {
820
1010
  );
821
1011
  let cred = await ensureLogin();
822
1012
  if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
823
- throw new Error(
1013
+ throw new PublishRefusal(
824
1014
  "The stored login changed while this command was running. Re-run it under the current account."
825
1015
  );
826
1016
  }
827
1017
  let res = await send(cred);
828
1018
  if (res.status === 401 || res.status === 409) {
1019
+ if (cred.source === "env") {
1020
+ throw new PublishRefusal(
1021
+ res.status === 401 ? `${ENV_TOKEN_REJECTED} Mint a new one with \`ymmv login\` on an interactive machine and update YMMV_TOKEN.` : "The server no longer accepts this handle for the YMMV_TOKEN account. Update YMMV_HANDLE, or mint a fresh token with `ymmv login`."
1022
+ );
1023
+ }
829
1024
  const was401 = res.status === 401;
1025
+ console.log(
1026
+ message(
1027
+ was401 ? "Session expired. Logging in again to retry the publish." : "The server no longer recognizes your handle. Logging in again to retry the publish."
1028
+ )
1029
+ );
830
1030
  if (was401) await deleteToken();
831
1031
  await login();
832
1032
  cred = await ensureLogin();
833
1033
  if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
834
1034
  const bound = sanitizeValue(cred.handle ?? "");
835
- throw new Error(
1035
+ throw new PublishRefusal(
836
1036
  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.`
837
1037
  );
838
1038
  }
839
1039
  res = await send(cred);
840
- if (res.status === 401) throw new Error("Authentication failed. Run `ymmv login`.");
1040
+ if (res.status === 401) throw new PublishRefusal("Authentication failed. Run `ymmv login`.");
841
1041
  if (res.status === 409) {
1042
+ const { slug, message: srvMsg } = wireErrorBody(await wireBody(res));
1043
+ if (slug === "handle_not_bound") {
1044
+ throw new PublishRefusal(
1045
+ "The server still refuses this handle after a fresh login. Wait a moment and re-run the command."
1046
+ );
1047
+ }
842
1048
  throw new Error(
843
- "that handle is taken by another account (your GitHub handle may have been reused)."
1049
+ srvMsg ?? "that handle is taken by another account (your GitHub handle may have been reused)."
844
1050
  );
845
1051
  }
846
1052
  }
847
1053
  if (res.status === 429) throw new Error(await rateLimitMessage(res));
848
1054
  if (!res.ok) {
849
- throw new Error(`publish failed: ${res.status} ${wireText(await res.text())}`);
1055
+ const raw = await wireBody(res);
1056
+ const parsed = wireErrorBody(raw);
1057
+ if (res.status === 400 && parsed.slug === "unsupported_schema_version") {
1058
+ throw new PublishRefusal(parsed.message ?? `publish failed: ${res.status} ${wireText(raw)}`);
1059
+ }
1060
+ throw new Error(parsed.message ?? `publish failed: ${res.status} ${wireText(raw)}`);
1061
+ }
1062
+ let data = {};
1063
+ try {
1064
+ data = await res.json();
1065
+ } catch {
850
1066
  }
851
- const data = await res.json();
852
1067
  const shown = typeof data.handle === "string" ? sanitizeValue(data.handle) : profile.handle;
853
1068
  return { handle: shown, url: `${BASE}/${shown}` };
854
1069
  }
@@ -860,8 +1075,7 @@ async function fetchProfileJson(handle) {
860
1075
  }
861
1076
  return parseProfile(await res.json());
862
1077
  }
863
- async function deleteProfile() {
864
- const cred = await ensureLogin();
1078
+ async function deleteProfile(cred) {
865
1079
  const res = await safeFetch(
866
1080
  `${BASE}/api/v1/profile`,
867
1081
  {
@@ -872,11 +1086,14 @@ async function deleteProfile() {
872
1086
  BASE
873
1087
  );
874
1088
  if (res.status === 401) {
875
- throw new Error("Session expired. Run `ymmv login`, then `ymmv delete` again.");
1089
+ throw new Error(
1090
+ cred.source === "env" ? `${ENV_TOKEN_REJECTED} Update YMMV_TOKEN and run \`ymmv delete\` again.` : "Session expired. Run `ymmv login`, then `ymmv delete` again."
1091
+ );
876
1092
  }
877
1093
  if (res.status === 429) throw new Error(await rateLimitMessage(res));
878
1094
  if (!res.ok) {
879
- throw new Error(`delete failed: ${res.status} ${wireText(await res.text())}`);
1095
+ const raw = await wireBody(res);
1096
+ throw new Error(wireErrorBody(raw).message ?? `delete failed: ${res.status} ${wireText(raw)}`);
880
1097
  }
881
1098
  }
882
1099
 
@@ -1245,7 +1462,7 @@ function requireHandle(cred) {
1245
1462
  if (cred.handle) return cred.handle;
1246
1463
  console.error(
1247
1464
  message(
1248
- "Your GitHub username is a reserved word, so no handle is bound. Rename on GitHub, then run `ymmv login` again."
1465
+ cred.source === "env" ? "YMMV_TOKEN is set but YMMV_HANDLE is not. Set YMMV_HANDLE to the GitHub username the token belongs to." : "Your GitHub username is a reserved word, so no handle is bound. Rename on GitHub, then run `ymmv login` again."
1249
1466
  )
1250
1467
  );
1251
1468
  process.exitCode = 1;
@@ -1280,9 +1497,21 @@ async function promptEntries(defaults, prompter) {
1280
1497
  console.log(message(`${c.faint}Enter to keep, "-" to clear${c.reset}`));
1281
1498
  const chosen = /* @__PURE__ */ new Map();
1282
1499
  for (const key of CURATED_KEYS) {
1283
- const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
1284
- const value = answer === "-" ? "" : answer;
1285
- if (value) chosen.set(key, value);
1500
+ for (; ; ) {
1501
+ const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
1502
+ const value = answer === "-" ? "" : answer;
1503
+ if (value.length > MAX_VALUE) {
1504
+ const isDefault = value === (defaults.get(key) ?? "").trim();
1505
+ console.log(
1506
+ message(
1507
+ `${c.faint}${isDefault ? `the saved value is ${value.length} characters; the cap is ${MAX_VALUE}. Type a shorter value or - to clear` : `that value is ${value.length} characters; the cap is ${MAX_VALUE}`}${c.reset}`
1508
+ )
1509
+ );
1510
+ continue;
1511
+ }
1512
+ if (value) chosen.set(key, value);
1513
+ break;
1514
+ }
1286
1515
  }
1287
1516
  return chosen;
1288
1517
  }
@@ -1337,6 +1566,16 @@ async function publish(io) {
1337
1566
  let values = defaults;
1338
1567
  const assemble = () => [...entriesFromMap(values), ...carried];
1339
1568
  if (!io.interactive || !io.prompter || io.yes) {
1569
+ const over = [...values].find(([, v]) => v.length > MAX_VALUE);
1570
+ if (over) {
1571
+ console.error(
1572
+ message(
1573
+ `The detected ${KEY_LABELS[over[0]]} value is ${over[1].length} characters; the cap is ${MAX_VALUE}. Set a shorter one: ymmv set ${over[0]} <value>.`
1574
+ )
1575
+ );
1576
+ process.exitCode = 1;
1577
+ return;
1578
+ }
1340
1579
  const entries = assemble();
1341
1580
  showCard(entries);
1342
1581
  printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
@@ -1354,8 +1593,20 @@ async function publish(io) {
1354
1593
  "Y/n/e=edit"
1355
1594
  );
1356
1595
  if (ans === "y") {
1357
- printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1358
- return;
1596
+ try {
1597
+ printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1598
+ return;
1599
+ } catch (e) {
1600
+ if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
1601
+ const ambiguous = e instanceof NetworkError || isTimeoutError(e);
1602
+ console.error(
1603
+ message(
1604
+ `${displayError(e)}
1605
+ ${ambiguous ? "The publish may not have completed. Your answers are kept." : "Nothing was published. Your answers are kept."}`
1606
+ )
1607
+ );
1608
+ continue;
1609
+ }
1359
1610
  }
1360
1611
  if (ans === "n") {
1361
1612
  console.log(message("Aborted. Nothing published."));
@@ -1382,7 +1633,11 @@ async function view(handle) {
1382
1633
  }
1383
1634
  const cred = await loadToken();
1384
1635
  if (cred?.handle) {
1385
- const mine = await fetchProfileJson(cred.handle).catch(() => null);
1636
+ let mineFailed = false;
1637
+ const mine = await fetchProfileJson(cred.handle).catch(() => {
1638
+ mineFailed = true;
1639
+ return null;
1640
+ });
1386
1641
  if (mine && mine.handle.toLowerCase() !== theirs.handle.toLowerCase()) {
1387
1642
  console.log(
1388
1643
  renderDiff(diff(mine, theirs), { color: c, theirsLabel: theirs.handle, mineLabel: "you" })
@@ -1391,7 +1646,12 @@ async function view(handle) {
1391
1646
  }
1392
1647
  if (!mine) {
1393
1648
  console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
1394
- console.log(nudge(c));
1649
+ if (mineFailed) {
1650
+ const codes = palette(c);
1651
+ console.error(message(`${codes.faint}(couldn't load your profile to diff)${codes.reset}`));
1652
+ } else {
1653
+ console.log(nudge(c));
1654
+ }
1395
1655
  return;
1396
1656
  }
1397
1657
  }
@@ -1404,6 +1664,15 @@ async function runSet(target) {
1404
1664
  const existing = await fetchProfileJson(handle);
1405
1665
  assertHandleUnchanged(existing, handle);
1406
1666
  const { entries, extras } = applySet(existing, target);
1667
+ if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
1668
+ console.error(
1669
+ message(
1670
+ `Your profile already has ${MAX_EXTRAS} extras; that's the cap. Remove one first: ymmv unset --extra "Label".`
1671
+ )
1672
+ );
1673
+ process.exitCode = 1;
1674
+ return;
1675
+ }
1407
1676
  const res = await publishProfile(newProfile(handle, entries, extras));
1408
1677
  const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
1409
1678
  console.log(message(`${line}${pagePointer(res.handle)}`));
@@ -1433,7 +1702,7 @@ async function runUnset(target) {
1433
1702
  }
1434
1703
  async function runDelete(io) {
1435
1704
  const cred = await ensureLogin();
1436
- const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
1705
+ const target = cred.source === "env" ? "the profile bound to YMMV_TOKEN" : cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
1437
1706
  if (!io.yes) {
1438
1707
  if (!io.interactive || !io.prompter) {
1439
1708
  console.error(
@@ -1461,8 +1730,8 @@ ${message("Cancelled. Nothing deleted.")}`);
1461
1730
  return;
1462
1731
  }
1463
1732
  }
1464
- await deleteProfile();
1465
- await deleteToken();
1733
+ await deleteProfile(cred);
1734
+ if (cred.source === "file") await deleteToken();
1466
1735
  console.log(message(`Deleted ${target}. Run \`ymmv\` to publish again.`));
1467
1736
  }
1468
1737
 
@@ -1472,15 +1741,33 @@ var UNSET_EXTRA = 'ymmv unset --extra "Label"';
1472
1741
  var SET_USAGE = `usage: ymmv set <key> <value> | ${SET_EXTRA}`;
1473
1742
  var EXTRA_USAGE = `usage: ${SET_EXTRA}`;
1474
1743
  var UNSET_USAGE = `usage: ymmv unset <key> | ${UNSET_EXTRA}`;
1744
+ var VIEW_USAGE = "usage: ymmv view <handle>";
1475
1745
  function invalidKeyError(head, hint) {
1476
1746
  return {
1477
1747
  kind: "error",
1478
- message: `"${head}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
1748
+ message: `"${sanitizeValue(head)}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
1479
1749
  For anything else, use: ${hint}.`
1480
1750
  };
1481
1751
  }
1482
- function hasYes(args) {
1483
- return args.includes("-y") || args.includes("--yes");
1752
+ function noArgs(verb, rest) {
1753
+ return rest.length === 0 ? { kind: verb } : { kind: "error", message: `usage: ymmv ${verb}` };
1754
+ }
1755
+ function yesOnly(usage, rest, make) {
1756
+ if (rest.length === 0) return make(false);
1757
+ if (rest.length === 1 && (rest[0] === "-y" || rest[0] === "--yes")) return make(true);
1758
+ return { kind: "error", message: usage };
1759
+ }
1760
+ function labelCapError(label) {
1761
+ return {
1762
+ kind: "error",
1763
+ message: `That label is ${label.length} characters; the cap is ${MAX_LABEL}.`
1764
+ };
1765
+ }
1766
+ function valueCapError(value) {
1767
+ return {
1768
+ kind: "error",
1769
+ message: `That value is ${value.length} characters; the cap is ${MAX_VALUE}.`
1770
+ };
1484
1771
  }
1485
1772
  function parseSet(rest) {
1486
1773
  const head = rest[0];
@@ -1492,6 +1779,8 @@ function parseSet(rest) {
1492
1779
  const value2 = spec.slice(eq + 1).trim();
1493
1780
  if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
1494
1781
  if (value2 === "-") return { kind: "unset", target: { kind: "extra", label } };
1782
+ if (label.length > MAX_LABEL) return labelCapError(label);
1783
+ if (value2.length > MAX_VALUE) return valueCapError(value2);
1495
1784
  return { kind: "set", target: { kind: "extra", label, value: value2 } };
1496
1785
  }
1497
1786
  if (!head) return { kind: "error", message: SET_USAGE };
@@ -1499,6 +1788,7 @@ function parseSet(rest) {
1499
1788
  const value = rest.slice(1).join(" ").trim();
1500
1789
  if (!value) return { kind: "error", message: `usage: ymmv set ${head} <value>` };
1501
1790
  if (value === "-") return { kind: "unset", target: { kind: "curated", key: head } };
1791
+ if (value.length > MAX_VALUE) return valueCapError(value);
1502
1792
  return { kind: "set", target: { kind: "curated", key: head, value } };
1503
1793
  }
1504
1794
  function parseUnset(rest) {
@@ -1521,34 +1811,71 @@ function parseUnset(rest) {
1521
1811
  }
1522
1812
  function resolveArg(argv) {
1523
1813
  const first = argv[0];
1814
+ const rest = argv.slice(1);
1524
1815
  if (first === "-h" || first === "--help" || first === "help") return { kind: "help" };
1525
- if (first === "-V" || first === "-v" || first === "--version") return { kind: "version" };
1816
+ if (first === "-V" || first === "-v" || first === "--version" || first === "version") {
1817
+ return rest.length === 0 ? { kind: "version" } : { kind: "error", message: "usage: ymmv version" };
1818
+ }
1526
1819
  if (first === void 0) return { kind: "publish", yes: false };
1527
- if (first === "-y" || first === "--yes") return { kind: "publish", yes: true };
1528
- if (first === "login") return { kind: "login" };
1529
- if (first === "logout") return { kind: "logout" };
1530
- if (first === "delete") return { kind: "delete", yes: hasYes(argv.slice(1)) };
1531
- if (first === "set") return parseSet(argv.slice(1));
1532
- if (first === "unset") return parseUnset(argv.slice(1));
1820
+ if (first === "-y" || first === "--yes") {
1821
+ if (rest.length > 0) {
1822
+ const example = rest[0] === "delete" || rest[0] === "publish" ? rest[0] : "publish";
1823
+ return {
1824
+ kind: "error",
1825
+ message: `Put ${first} after the command: ymmv ${example} -y. A bare ymmv -y publishes without prompts.`
1826
+ };
1827
+ }
1828
+ return { kind: "publish", yes: true };
1829
+ }
1830
+ if (first === "login" || first === "logout") return noArgs(first, rest);
1831
+ if (first === "publish") {
1832
+ return yesOnly("usage: ymmv publish [-y]", rest, (yes) => ({ kind: "publish", yes }));
1833
+ }
1834
+ if (first === "delete") {
1835
+ return yesOnly(
1836
+ "usage: ymmv delete [-y] (deletes your own profile; takes no handle)",
1837
+ rest,
1838
+ (yes) => ({ kind: "delete", yes })
1839
+ );
1840
+ }
1841
+ if (first === "set") return parseSet(rest);
1842
+ if (first === "unset") return parseUnset(rest);
1533
1843
  if (first === "view") {
1534
- const handle = argv[1];
1535
- if (!handle) return { kind: "error", message: "usage: ymmv view <handle>" };
1844
+ const handle = rest[0];
1845
+ if (!handle) return { kind: "error", message: VIEW_USAGE };
1536
1846
  if (!isValidHandle(handle)) {
1537
- return { kind: "error", message: `"${handle}" is not a valid GitHub handle.` };
1847
+ return { kind: "error", message: `"${sanitizeValue(handle)}" is not a valid GitHub handle.` };
1538
1848
  }
1849
+ if (isReserved(handle)) return reservedError(handle);
1850
+ if (rest.length > 1) return { kind: "error", message: VIEW_USAGE };
1539
1851
  return { kind: "view", handle };
1540
1852
  }
1541
1853
  if (first.startsWith("-")) {
1542
- return { kind: "error", message: `Unknown option "${first}". Run \`ymmv help\`.` };
1854
+ return {
1855
+ kind: "error",
1856
+ message: `Unknown option "${sanitizeValue(first)}". Run \`ymmv help\`.`
1857
+ };
1543
1858
  }
1544
1859
  if (!isValidHandle(first)) {
1545
1860
  return {
1546
1861
  kind: "error",
1547
- message: `"${first}" is not a valid GitHub handle. Run \`ymmv help\`.`
1862
+ message: `"${sanitizeValue(first)}" is not a valid GitHub handle. Run \`ymmv help\`.`
1548
1863
  };
1549
1864
  }
1865
+ if (isReserved(first)) return reservedError(first, true);
1866
+ if (rest.length > 0) {
1867
+ return { kind: "error", message: `Unexpected arguments after "${first}". Run \`ymmv help\`.` };
1868
+ }
1550
1869
  return { kind: "view", handle: first };
1551
1870
  }
1871
+ function reservedError(handle, hintVerbs = false) {
1872
+ const verb = handle.toLowerCase();
1873
+ const hint = hintVerbs && CLI_VERBS.includes(verb) ? ` Did you mean: ymmv ${verb}?` : "";
1874
+ return {
1875
+ kind: "error",
1876
+ message: `"${handle}" is a reserved name; it can't have a profile.${hint}`
1877
+ };
1878
+ }
1552
1879
 
1553
1880
  // src/index.ts
1554
1881
  var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
@@ -1557,14 +1884,14 @@ ${c.faint}Usage:${c.reset}
1557
1884
  ymmv detect your stack, confirm, and publish your profile
1558
1885
  ymmv -y publish without prompts (required when stdin isn't a TTY)
1559
1886
  ymmv <handle> view a profile; logged in, see the diff vs yours
1560
- ymmv view <handle> explicit view (when a handle collides with a verb)
1887
+ ymmv view <handle> explicit view (same as ymmv <handle>)
1561
1888
  ymmv set <key> <value> set one curated key
1562
- ymmv set --extra "L=V" set a free-form extra
1889
+ ymmv set --extra "L=V" set a free-form extra (-e works too)
1563
1890
  ymmv unset <key> remove one curated key (ymmv set <key> - works too)
1564
1891
  ymmv unset --extra "L" remove a free-form extra
1565
- ymmv delete delete your profile (permanent)
1892
+ ymmv delete [-y] delete your profile (permanent; -y skips the confirm)
1566
1893
  ymmv login | logout GitHub device-flow auth
1567
- ymmv help | --version
1894
+ ymmv help | version
1568
1895
 
1569
1896
  ${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser, window-manager,
1570
1897
  font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
@@ -1574,7 +1901,7 @@ async function logout() {
1574
1901
  const otherBase = await peekBase();
1575
1902
  console.log(
1576
1903
  message(
1577
- 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."
1904
+ otherBase && otherBase !== BASE ? `Not logged in to ${BASE} (a token for ${sanitizeValue(otherBase)} exists; set YMMV_API to that to log out of it).` : "Not logged in."
1578
1905
  )
1579
1906
  );
1580
1907
  return;
@@ -1582,10 +1909,10 @@ async function logout() {
1582
1909
  let revoked;
1583
1910
  try {
1584
1911
  revoked = await revokeYmmvToken(stored.token);
1585
- } catch {
1912
+ } catch (e) {
1586
1913
  console.error(
1587
1914
  message(
1588
- "Couldn't reach the server to revoke. Your token is still active. Run `ymmv logout` again when connected."
1915
+ e instanceof NetworkError || isTimeoutError(e) ? "Couldn't reach the server to revoke. Your token is still active. Run `ymmv logout` again when connected." : "The server didn't confirm the revoke. Your token is still active. Run `ymmv logout` again shortly."
1589
1916
  )
1590
1917
  );
1591
1918
  process.exitCode = 1;
@@ -1613,6 +1940,14 @@ async function interactive(run, yes) {
1613
1940
  }
1614
1941
  async function main(argv) {
1615
1942
  const cmd = resolveArg(argv);
1943
+ if (cmd.kind !== "logout") {
1944
+ const problem = baseProblem() ?? credentialEnvProblem();
1945
+ if (problem) {
1946
+ console.error(message(problem));
1947
+ process.exitCode = 1;
1948
+ return;
1949
+ }
1950
+ }
1616
1951
  switch (cmd.kind) {
1617
1952
  case "publish":
1618
1953
  await interactive(publish, cmd.yes);
@@ -1637,6 +1972,13 @@ async function main(argv) {
1637
1972
  }
1638
1973
  case "logout":
1639
1974
  await logout();
1975
+ if (process.env.YMMV_TOKEN) {
1976
+ console.error(
1977
+ message(
1978
+ "Note: YMMV_TOKEN is set and still authenticates API calls. Unset it to stop using that token."
1979
+ )
1980
+ );
1981
+ }
1640
1982
  break;
1641
1983
  case "help":
1642
1984
  console.log(help(palette(colorEnabled())));
@@ -1653,7 +1995,7 @@ async function main(argv) {
1653
1995
 
1654
1996
  // src/cli.ts
1655
1997
  main(process.argv.slice(2)).catch((err) => {
1656
- console.error(message(err instanceof Error ? err.message : String(err)));
1998
+ console.error(message(displayError(err)));
1657
1999
  process.exitCode = 1;
1658
2000
  }).finally(() => {
1659
2001
  (process.exitCode ? process.stderr : process.stdout).write("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {