ymmv-cli 0.6.1 → 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 (3) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +109 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # ymmv-cli
2
2
 
3
- **Share your dev tool-stack from the terminal.**
3
+ **The tools you actually use. Publish from the CLI, diff against anyone's.**
4
4
 
5
- Editor, OS, shell, terminal, theme, AI tool (and more), published to a
5
+ Editor, OS, shell, terminal, theme (and more), published to a
6
6
  clean page at `ymmv.fyi/<handle>` in about 10 seconds. See a live one:
7
7
  [ymmv.fyi/bardisty](https://ymmv.fyi/bardisty).
8
8
 
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",
@@ -286,8 +280,14 @@ function parseProfile(raw) {
286
280
  }
287
281
  if (typeof raw.handle !== "string")
288
282
  throw new ProfileParseError("handle is not a string");
283
+ if (raw.handle.length > MAX_PARSE_LABEL) {
284
+ throw new ProfileParseError(`handle exceeds ${MAX_PARSE_LABEL} chars`);
285
+ }
289
286
  if (typeof raw.updated_at !== "string")
290
287
  throw new ProfileParseError("updated_at is not a string");
288
+ if (raw.updated_at.length > MAX_PARSE_LABEL) {
289
+ throw new ProfileParseError(`updated_at exceeds ${MAX_PARSE_LABEL} chars`);
290
+ }
291
291
  if (!Array.isArray(raw.entries))
292
292
  throw new ProfileParseError("entries is not an array");
293
293
  if (raw.entries.length > MAX_PARSE_ENTRIES) {
@@ -299,6 +299,9 @@ function parseProfile(raw) {
299
299
  if (!isRecord(entry) || typeof entry.key !== "string" || typeof entry.value !== "string") {
300
300
  throw new ProfileParseError(`entry ${i} is not {key,value} strings`);
301
301
  }
302
+ if (entry.key.length > MAX_PARSE_LABEL) {
303
+ throw new ProfileParseError(`entry ${i} key exceeds ${MAX_PARSE_LABEL} chars`);
304
+ }
302
305
  if (entry.value.length > MAX_PARSE_VALUE) {
303
306
  throw new ProfileParseError(`entry ${i} value exceeds ${MAX_PARSE_VALUE} chars`);
304
307
  }
@@ -330,10 +333,13 @@ function parseProfile(raw) {
330
333
  }
331
334
 
332
335
  // ../shared/dist/reserved.js
333
- var RESERVED_ROUTES = ["api", "login", "logout"];
336
+ var RESERVED_ROUTES = ["404", "api", "login", "logout"];
334
337
  var CLI_VERBS = ["login", "logout", "set", "unset", "delete", "view", "help"];
335
338
  var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
336
339
  var RESERVED_SET = new Set(RESERVED);
340
+ function isReserved(handle) {
341
+ return RESERVED_SET.has(handle.toLowerCase());
342
+ }
337
343
  var HANDLE_RE = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$/;
338
344
  function isValidHandle(handle) {
339
345
  return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
@@ -517,6 +523,11 @@ function notFound(handle, color, base) {
517
523
  }
518
524
 
519
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
+ }
520
531
  function causeText(err) {
521
532
  const pick = (e) => {
522
533
  if (e instanceof AggregateError) {
@@ -527,6 +538,7 @@ function causeText(err) {
527
538
  }
528
539
  return e.message;
529
540
  };
541
+ if (isTimeoutError(err)) return TIMEOUT_TEXT;
530
542
  let text;
531
543
  if (err instanceof Error) {
532
544
  const fromCause = err.cause instanceof Error ? pick(err.cause) : "";
@@ -540,9 +552,34 @@ function wireText(text) {
540
552
  const clean = sanitizeValue(String(text));
541
553
  return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
542
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
+ }
543
577
  async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
544
578
  try {
545
- return await fetchFn(url, init);
579
+ return await fetchFn(url, {
580
+ ...init,
581
+ signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
582
+ });
546
583
  } catch (err) {
547
584
  throw new Error(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
548
585
  cause: err
@@ -550,7 +587,21 @@ async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
550
587
  }
551
588
  }
552
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
+
553
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
+ }
554
605
  async function mintYmmvToken(accessToken) {
555
606
  const res = await safeFetch(
556
607
  `${BASE}/api/v1/auth/token`,
@@ -566,20 +617,23 @@ async function mintYmmvToken(accessToken) {
566
617
  BASE
567
618
  );
568
619
  if (!res.ok) {
569
- const body = await res.json().catch(() => ({}));
570
620
  if (res.status === 503) {
571
621
  throw new Error(
572
- 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."
573
623
  );
574
624
  }
575
625
  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);
626
+ throw new Error(
627
+ withRetryHint(
628
+ await serverMessage(res) ?? "Too many login attempts. Slow down and try again shortly",
629
+ res
630
+ )
631
+ );
579
632
  }
633
+ const body = await bodyJson(res) ?? {};
580
634
  throw new Error(`login failed: ${res.status} ${wireText(body.error ?? "")}`.trim());
581
635
  }
582
- const data = await res.json().catch(() => null);
636
+ const data = await bodyJson(res);
583
637
  if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
584
638
  throw new Error(
585
639
  `Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`
@@ -588,16 +642,23 @@ async function mintYmmvToken(accessToken) {
588
642
  return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
589
643
  }
590
644
  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
- });
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
+ );
598
656
  if (!res.ok) throw new Error(`logout failed: ${res.status}`);
599
- const body = await res.json().catch(() => ({}));
600
- 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;
601
662
  }
602
663
 
603
664
  // src/commands.ts
@@ -676,7 +737,10 @@ async function requestDeviceCode(deps = {}) {
676
737
  if (!res.ok) {
677
738
  throw new Error(`device code request failed: ${res.status} ${wireText(await res.text())}`);
678
739
  }
679
- 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
+ });
680
744
  if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.expires_in !== "number") {
681
745
  throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
682
746
  }
@@ -693,6 +757,7 @@ async function pollForToken(dc, deps = {}) {
693
757
  const MAX_TRANSIENT_FAILURES = 5;
694
758
  while (now() < deadline) {
695
759
  await sleep(interval * 1e3);
760
+ if (now() >= deadline) break;
696
761
  let res;
697
762
  try {
698
763
  res = await doFetch(TOKEN_URL, {
@@ -702,7 +767,12 @@ async function pollForToken(dc, deps = {}) {
702
767
  client_id: GITHUB_CLIENT_ID,
703
768
  device_code: dc.device_code,
704
769
  grant_type: "urn:ietf:params:oauth:grant-type:device_code"
705
- })
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)
706
776
  });
707
777
  } catch (err) {
708
778
  lastCause = causeText(err);
@@ -711,9 +781,9 @@ async function pollForToken(dc, deps = {}) {
711
781
  if (res?.ok) {
712
782
  try {
713
783
  tok = await res.json();
714
- } catch {
784
+ } catch (err) {
715
785
  tok = void 0;
716
- lastCause = "unexpected response body";
786
+ lastCause = isTimeoutError(err) ? causeText(err) : "unexpected response body";
717
787
  }
718
788
  } else if (res) {
719
789
  lastCause = `HTTP ${res.status}`;
@@ -778,14 +848,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
778
848
 
779
849
  // src/api.ts
780
850
  async function rateLimitMessage(res) {
781
- const retry = res.headers.get("retry-after");
782
- let msg = "rate limited, too many requests";
783
- try {
784
- const body = await res.json();
785
- if (typeof body?.message === "string" && body.message) msg = wireText(body.message);
786
- } catch {
787
- }
788
- return retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg;
851
+ return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
789
852
  }
790
853
  async function ensureLogin() {
791
854
  const existing = await loadToken();
@@ -831,7 +894,7 @@ async function publishProfile(profile) {
831
894
  if (res.status === 401) throw new Error("Authentication failed. Run `ymmv login`.");
832
895
  if (res.status === 409) {
833
896
  throw new Error(
834
- "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)."
835
898
  );
836
899
  }
837
900
  }
@@ -1527,6 +1590,7 @@ function resolveArg(argv) {
1527
1590
  if (!isValidHandle(handle)) {
1528
1591
  return { kind: "error", message: `"${handle}" is not a valid GitHub handle.` };
1529
1592
  }
1593
+ if (isReserved(handle)) return reservedError(handle);
1530
1594
  return { kind: "view", handle };
1531
1595
  }
1532
1596
  if (first.startsWith("-")) {
@@ -1538,8 +1602,15 @@ function resolveArg(argv) {
1538
1602
  message: `"${first}" is not a valid GitHub handle. Run \`ymmv help\`.`
1539
1603
  };
1540
1604
  }
1605
+ if (isReserved(first)) return reservedError(first);
1541
1606
  return { kind: "view", handle: first };
1542
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
+ }
1543
1614
 
1544
1615
  // src/index.ts
1545
1616
  var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
@@ -1548,7 +1619,7 @@ ${c.faint}Usage:${c.reset}
1548
1619
  ymmv detect your stack, confirm, and publish your profile
1549
1620
  ymmv -y publish without prompts (required when stdin isn't a TTY)
1550
1621
  ymmv <handle> view a profile; logged in, see the diff vs yours
1551
- ymmv view <handle> explicit view (when a handle collides with a verb)
1622
+ ymmv view <handle> explicit view (same as ymmv <handle>)
1552
1623
  ymmv set <key> <value> set one curated key
1553
1624
  ymmv set --extra "L=V" set a free-form extra
1554
1625
  ymmv unset <key> remove one curated key (ymmv set <key> - works too)
@@ -1644,7 +1715,7 @@ async function main(argv) {
1644
1715
 
1645
1716
  // src/cli.ts
1646
1717
  main(process.argv.slice(2)).catch((err) => {
1647
- console.error(message(err instanceof Error ? err.message : String(err)));
1718
+ console.error(message(displayError(err)));
1648
1719
  process.exitCode = 1;
1649
1720
  }).finally(() => {
1650
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.1",
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": {