ymmv-cli 0.6.2 → 0.7.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 (2) hide show
  1. package/dist/cli.js +99 -37
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,11 +1,5 @@
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(/\/+$/, "");
8
-
9
3
  // ../shared/dist/keys.js
10
4
  var CURATED_KEYS = [
11
5
  "editor",
@@ -343,6 +337,9 @@ var RESERVED_ROUTES = ["404", "api", "login", "logout"];
343
337
  var CLI_VERBS = ["login", "logout", "set", "unset", "delete", "view", "help"];
344
338
  var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
345
339
  var RESERVED_SET = new Set(RESERVED);
340
+ function isReserved(handle) {
341
+ return RESERVED_SET.has(handle.toLowerCase());
342
+ }
346
343
  var HANDLE_RE = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$/;
347
344
  function isValidHandle(handle) {
348
345
  return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
@@ -526,6 +523,11 @@ function notFound(handle, color, base) {
526
523
  }
527
524
 
528
525
  // src/http.ts
526
+ var REQUEST_TIMEOUT_MS = 3e4;
527
+ var TIMEOUT_TEXT = "request timed out";
528
+ function isTimeoutError(err) {
529
+ return err instanceof Error && (err.name === "TimeoutError" || err.cause instanceof Error && err.cause.name === "TimeoutError");
530
+ }
529
531
  function causeText(err) {
530
532
  const pick = (e) => {
531
533
  if (e instanceof AggregateError) {
@@ -536,6 +538,7 @@ function causeText(err) {
536
538
  }
537
539
  return e.message;
538
540
  };
541
+ if (isTimeoutError(err)) return TIMEOUT_TEXT;
539
542
  let text;
540
543
  if (err instanceof Error) {
541
544
  const fromCause = err.cause instanceof Error ? pick(err.cause) : "";
@@ -549,9 +552,34 @@ function wireText(text) {
549
552
  const clean = sanitizeValue(String(text));
550
553
  return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
551
554
  }
555
+ var INVISIBLE_RE = new RegExp("\\p{Default_Ignorable_Code_Point}", "gu");
556
+ async function serverMessage(res) {
557
+ try {
558
+ const body = await res.json();
559
+ if (typeof body?.message === "string") {
560
+ const clean = wireText(body.message).trim();
561
+ if (clean.replace(INVISIBLE_RE, "").trim()) return clean;
562
+ }
563
+ } catch (err) {
564
+ if (isTimeoutError(err)) throw err;
565
+ }
566
+ return void 0;
567
+ }
568
+ function withRetryHint(msg, res) {
569
+ const retry = res.headers.get("retry-after");
570
+ return retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg;
571
+ }
572
+ function displayError(err) {
573
+ if (err instanceof Error && err.name === "TimeoutError") return TIMEOUT_TEXT;
574
+ const text = err instanceof Error ? err.message : String(err);
575
+ return text.split(/\r?\n/).map((line) => sanitizeValue(line)).join("\n");
576
+ }
552
577
  async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
553
578
  try {
554
- return await fetchFn(url, init);
579
+ return await fetchFn(url, {
580
+ ...init,
581
+ signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
582
+ });
555
583
  } catch (err) {
556
584
  throw new Error(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
557
585
  cause: err
@@ -559,7 +587,21 @@ async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
559
587
  }
560
588
  }
561
589
 
590
+ // src/index.ts
591
+ import { readFileSync as readFileSync2 } from "fs";
592
+
593
+ // src/config.ts
594
+ var BASE = (process.env.YMMV_API ?? "https://ymmv.fyi").replace(/\/+$/, "");
595
+
562
596
  // src/auth-http.ts
597
+ async function bodyJson(res) {
598
+ try {
599
+ return await res.json();
600
+ } catch (err) {
601
+ if (isTimeoutError(err)) throw err;
602
+ return null;
603
+ }
604
+ }
563
605
  async function mintYmmvToken(accessToken) {
564
606
  const res = await safeFetch(
565
607
  `${BASE}/api/v1/auth/token`,
@@ -575,20 +617,23 @@ async function mintYmmvToken(accessToken) {
575
617
  BASE
576
618
  );
577
619
  if (!res.ok) {
578
- const body = await res.json().catch(() => ({}));
579
620
  if (res.status === 503) {
580
621
  throw new Error(
581
- body.message ? wireText(body.message) : "GitHub is unavailable. Run `ymmv login` again shortly."
622
+ await serverMessage(res) ?? "GitHub is unavailable. Run `ymmv login` again shortly."
582
623
  );
583
624
  }
584
625
  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);
626
+ throw new Error(
627
+ withRetryHint(
628
+ await serverMessage(res) ?? "Too many login attempts. Slow down and try again shortly",
629
+ res
630
+ )
631
+ );
588
632
  }
633
+ const body = await bodyJson(res) ?? {};
589
634
  throw new Error(`login failed: ${res.status} ${wireText(body.error ?? "")}`.trim());
590
635
  }
591
- const data = await res.json().catch(() => null);
636
+ const data = await bodyJson(res);
592
637
  if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
593
638
  throw new Error(
594
639
  `Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`
@@ -597,16 +642,23 @@ async function mintYmmvToken(accessToken) {
597
642
  return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
598
643
  }
599
644
  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
- });
645
+ const res = await safeFetch(
646
+ `${BASE}/api/v1/auth/logout`,
647
+ {
648
+ method: "POST",
649
+ headers: { authorization: `Bearer ${token}` },
650
+ // Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete
651
+ // the local file while the server token stays live). Same guard as mint + publish/delete.
652
+ redirect: "manual"
653
+ },
654
+ BASE
655
+ );
607
656
  if (!res.ok) throw new Error(`logout failed: ${res.status}`);
608
- const body = await res.json().catch(() => ({}));
609
- return body.revoked === true;
657
+ const body = await bodyJson(res);
658
+ if (!body || typeof body.revoked !== "boolean") {
659
+ throw new Error("logout failed: unexpected response");
660
+ }
661
+ return body.revoked;
610
662
  }
611
663
 
612
664
  // src/commands.ts
@@ -685,7 +737,10 @@ async function requestDeviceCode(deps = {}) {
685
737
  if (!res.ok) {
686
738
  throw new Error(`device code request failed: ${res.status} ${wireText(await res.text())}`);
687
739
  }
688
- const data = await res.json().catch(() => null);
740
+ const data = await res.json().catch((err) => {
741
+ if (isTimeoutError(err)) throw err;
742
+ return null;
743
+ });
689
744
  if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.expires_in !== "number") {
690
745
  throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
691
746
  }
@@ -702,6 +757,7 @@ async function pollForToken(dc, deps = {}) {
702
757
  const MAX_TRANSIENT_FAILURES = 5;
703
758
  while (now() < deadline) {
704
759
  await sleep(interval * 1e3);
760
+ if (now() >= deadline) break;
705
761
  let res;
706
762
  try {
707
763
  res = await doFetch(TOKEN_URL, {
@@ -711,7 +767,12 @@ async function pollForToken(dc, deps = {}) {
711
767
  client_id: GITHUB_CLIENT_ID,
712
768
  device_code: dc.device_code,
713
769
  grant_type: "urn:ietf:params:oauth:grant-type:device_code"
714
- })
770
+ }),
771
+ // GitHub answers this endpoint immediately — slow_down pacing lives in the sleep above,
772
+ // never inside a held request — so the per-request timeout can't cut pacing short. It only
773
+ // turns a HUNG poll into a TimeoutError feeding the transient counter (previously a hang
774
+ // here stalled the login forever: the deadline is only checked between iterations).
775
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
715
776
  });
716
777
  } catch (err) {
717
778
  lastCause = causeText(err);
@@ -720,9 +781,9 @@ async function pollForToken(dc, deps = {}) {
720
781
  if (res?.ok) {
721
782
  try {
722
783
  tok = await res.json();
723
- } catch {
784
+ } catch (err) {
724
785
  tok = void 0;
725
- lastCause = "unexpected response body";
786
+ lastCause = isTimeoutError(err) ? causeText(err) : "unexpected response body";
726
787
  }
727
788
  } else if (res) {
728
789
  lastCause = `HTTP ${res.status}`;
@@ -787,14 +848,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
787
848
 
788
849
  // src/api.ts
789
850
  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 {
796
- }
797
- return retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg;
851
+ return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
798
852
  }
799
853
  async function ensureLogin() {
800
854
  const existing = await loadToken();
@@ -840,7 +894,7 @@ async function publishProfile(profile) {
840
894
  if (res.status === 401) throw new Error("Authentication failed. Run `ymmv login`.");
841
895
  if (res.status === 409) {
842
896
  throw new Error(
843
- "that handle is taken by another account (your GitHub handle may have been reused)."
897
+ await serverMessage(res) ?? "that handle is taken by another account (your GitHub handle may have been reused)."
844
898
  );
845
899
  }
846
900
  }
@@ -1536,6 +1590,7 @@ function resolveArg(argv) {
1536
1590
  if (!isValidHandle(handle)) {
1537
1591
  return { kind: "error", message: `"${handle}" is not a valid GitHub handle.` };
1538
1592
  }
1593
+ if (isReserved(handle)) return reservedError(handle);
1539
1594
  return { kind: "view", handle };
1540
1595
  }
1541
1596
  if (first.startsWith("-")) {
@@ -1547,8 +1602,15 @@ function resolveArg(argv) {
1547
1602
  message: `"${first}" is not a valid GitHub handle. Run \`ymmv help\`.`
1548
1603
  };
1549
1604
  }
1605
+ if (isReserved(first)) return reservedError(first);
1550
1606
  return { kind: "view", handle: first };
1551
1607
  }
1608
+ function reservedError(handle) {
1609
+ return {
1610
+ kind: "error",
1611
+ message: `"${handle}" is a reserved name; it can't have a profile.`
1612
+ };
1613
+ }
1552
1614
 
1553
1615
  // src/index.ts
1554
1616
  var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
@@ -1557,7 +1619,7 @@ ${c.faint}Usage:${c.reset}
1557
1619
  ymmv detect your stack, confirm, and publish your profile
1558
1620
  ymmv -y publish without prompts (required when stdin isn't a TTY)
1559
1621
  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)
1622
+ ymmv view <handle> explicit view (same as ymmv <handle>)
1561
1623
  ymmv set <key> <value> set one curated key
1562
1624
  ymmv set --extra "L=V" set a free-form extra
1563
1625
  ymmv unset <key> remove one curated key (ymmv set <key> - works too)
@@ -1653,7 +1715,7 @@ async function main(argv) {
1653
1715
 
1654
1716
  // src/cli.ts
1655
1717
  main(process.argv.slice(2)).catch((err) => {
1656
- console.error(message(err instanceof Error ? err.message : String(err)));
1718
+ console.error(message(displayError(err)));
1657
1719
  process.exitCode = 1;
1658
1720
  }).finally(() => {
1659
1721
  (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.7.0",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {