ymmv-cli 0.10.0 → 0.11.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 +18 -11
  2. package/dist/cli.js +103 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -70,15 +70,18 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
70
70
  - `NO_COLOR` disables color output (and `FORCE_COLOR=0`/`false` force-disables it).
71
71
  - `YMMV_API` points the CLI at a different Worker (development/staging). Bare origin only. That
72
72
  Worker must be deployed with or after this CLI release: `ymmv login` requires the account id the
73
- login response carries and refuses (revoking what it minted) otherwise.
73
+ login response carries and refuses (revoking what it minted) otherwise, and `YMMV_TOKEN` needs
74
+ the server's account lookup.
74
75
  - `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
75
76
  the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
76
- `ymmv login` / `ymmv logout` keep acting on the stored login. Viewing (`ymmv <handle>`) also
77
- keeps using the stored login for the you-side of a diff. The token is sent to the server
78
- `YMMV_API` selects, so set the two together.
79
- - `YMMV_HANDLE` names the GitHub username `YMMV_TOKEN` belongs to. Required for `ymmv -y` and
80
- `ymmv set`/`unset` under an env token (there is no server lookup for it); ignored without
81
- `YMMV_TOKEN`.
77
+ `ymmv login` / `ymmv logout` keep acting on the stored login. The CLI asks the server which
78
+ account the token belongs to, so every command, including the you-side of a `ymmv <handle>`
79
+ diff, runs as that account. The token is sent to the server `YMMV_API` selects, so set the
80
+ two together.
81
+ - `YMMV_HANDLE` is optional. When set, it must name the GitHub username `YMMV_TOKEN` belongs
82
+ to: if the server reports a different account, `ymmv -y`, `ymmv set`/`unset`, and
83
+ `ymmv delete` refuse before sending anything, and `ymmv <handle>` shows the profile without
84
+ a diff. Ignored without `YMMV_TOKEN`.
82
85
  - `YMMV_NO_UPDATE_CHECK` disables the startup check for newer releases (the ecosystem-standard
83
86
  `NO_UPDATE_NOTIFIER` works too). The check is also off automatically under `CI`, in pipes,
84
87
  and in dev builds; it never blocks or fails a command.
@@ -91,7 +94,8 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
91
94
  2. Copy the `token` value from the token file:
92
95
  `~/.config/ymmv/token.json` (Linux), `~/Library/Preferences/ymmv/token.json` (macOS),
93
96
  `%APPDATA%\ymmv\Config\token.json` (Windows).
94
- 3. Set it as a CI secret named `YMMV_TOKEN`, and set `YMMV_HANDLE` to your GitHub username.
97
+ 3. Set it as a CI secret named `YMMV_TOKEN`. Setting `YMMV_HANDLE` to your GitHub username is
98
+ optional, and makes the job fail if the secret ever holds another account's token.
95
99
  4. Run `npx ymmv-cli@latest -y` in the job.
96
100
 
97
101
  Two things to know:
@@ -100,9 +104,12 @@ Two things to know:
100
104
  runs on. Values you already published always win, but curated keys you have never set get the
101
105
  CI runner's detected values (its OS, shell, and so on). For targeted updates from CI, prefer
102
106
  `ymmv set <key> <value>`.
103
- - A rejected or revoked `YMMV_TOKEN` fails with an error naming the variable; nothing falls back
104
- to an interactive login, and the stored login file on the runner (if any) is left untouched.
105
- `ymmv delete` acts on the account the token is bound to, regardless of `YMMV_HANDLE`.
107
+ - A rejected or revoked `YMMV_TOKEN` fails `ymmv -y`, `ymmv set`/`unset`, and `ymmv delete` with
108
+ an error naming the variable; nothing falls back to an interactive login, and the stored login
109
+ file on the runner (if any) is left untouched. `ymmv <handle>` still shows the profile, with the
110
+ reason there is no diff on stderr, and exits 0.
111
+ `ymmv delete` acts on the account the token is bound to and names that account's page when
112
+ it asks for confirmation.
106
113
 
107
114
  ## License
108
115
 
package/dist/cli.js CHANGED
@@ -684,6 +684,17 @@ async function bodyJson(res) {
684
684
  return null;
685
685
  }
686
686
  }
687
+ var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (invalid or revoked).";
688
+ var ENV_TOKEN_REJECTED_MINT_AGAIN = `${ENV_TOKEN_REJECTED} Mint a new one with \`ymmv login\` on an interactive machine and update YMMV_TOKEN.`;
689
+ function parseIdentity(data) {
690
+ if (typeof data !== "object" || data === null) return null;
691
+ const { handle: rawHandle, github_id: id } = data;
692
+ const handle = rawHandle === null ? null : typeof rawHandle === "string" ? sanitizeValue(rawHandle) : void 0;
693
+ if (handle === void 0 || handle !== null && !isValidHandle(handle) || !isGithubId(id)) {
694
+ return null;
695
+ }
696
+ return { github_id: id, handle };
697
+ }
687
698
  var MintRejected = class extends Error {
688
699
  constructor(msg) {
689
700
  super(msg);
@@ -734,8 +745,8 @@ async function mintYmmvToken(accessToken) {
734
745
  const unexpected = `Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`;
735
746
  if (!data) throw new Error(unexpected);
736
747
  const token = typeof data.token === "string" && data.token.length > 0 ? data.token : null;
737
- const handle = data.handle === null ? null : typeof data.handle === "string" ? sanitizeValue(data.handle) : void 0;
738
- if (token === null || handle === void 0 || handle !== null && !isValidHandle(handle) || !isGithubId(data.github_id)) {
748
+ const identity = parseIdentity(data);
749
+ if (token === null || identity === null) {
739
750
  let revokeFailed = false;
740
751
  if (token !== null) {
741
752
  await revokeYmmvToken(token, AbortSignal.timeout(REVOKE_CAP_MS)).catch(() => {
@@ -746,7 +757,38 @@ async function mintYmmvToken(accessToken) {
746
757
  revokeFailed ? `${unexpected} The login the server minted could not be revoked.` : unexpected
747
758
  );
748
759
  }
749
- return { token, handle, github_id: data.github_id };
760
+ return { token, ...identity };
761
+ }
762
+ async function fetchWhoami(token) {
763
+ const res = await safeFetch(
764
+ `${BASE}/api/v1/auth/whoami`,
765
+ {
766
+ headers: { authorization: `Bearer ${token}` },
767
+ // Never follow a redirect: the bearer must not travel to a redirect target, and a 30x→200
768
+ // must not read as a verified identity. Same guard as mint, logout, publish, and delete.
769
+ redirect: "manual"
770
+ },
771
+ BASE
772
+ );
773
+ if (!res.ok) {
774
+ if (res.status === 401) throw new Error(ENV_TOKEN_REJECTED_MINT_AGAIN);
775
+ if (res.status === 404) {
776
+ throw new Error(
777
+ `${BASE} has no identity lookup for YMMV_TOKEN. ${process.env.YMMV_API ? "Point YMMV_API at an up-to-date server." : "The server is behind this CLI release; try again later."}`
778
+ );
779
+ }
780
+ throw new Error(
781
+ withRetryHint(
782
+ await serverMessage(res) ?? (res.status === 429 ? "rate limited, too many requests" : `The server couldn't look up your token (${res.status}). Try again shortly.`),
783
+ res
784
+ )
785
+ );
786
+ }
787
+ const identity = parseIdentity(await bodyJson(res));
788
+ if (!identity) {
789
+ throw new Error(`Unexpected response from ${BASE}. Check YMMV_API, or try again shortly.`);
790
+ }
791
+ return identity;
750
792
  }
751
793
  async function revokeYmmvToken(token, signal) {
752
794
  const res = await safeFetch(
@@ -1016,8 +1058,14 @@ var PublishRefusal = class extends Error {
1016
1058
  this.name = "PublishRefusal";
1017
1059
  }
1018
1060
  };
1019
- var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (revoked or expired).";
1020
1061
  var NOT_PERSISTED = "Login did not persist a token. Run `ymmv login`.";
1062
+ function assertVerified(cred) {
1063
+ if (cred.source === "env" && cred.github_id === null) {
1064
+ throw new PublishRefusal(
1065
+ "Internal error: the YMMV_TOKEN credential was not verified. This is a ymmv-cli bug; please report it at https://github.com/ymmv-fyi/ymmv/issues."
1066
+ );
1067
+ }
1068
+ }
1021
1069
  async function rateLimitMessage(res) {
1022
1070
  return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
1023
1071
  }
@@ -1032,9 +1080,20 @@ async function loginOrRefuse() {
1032
1080
  if (!fresh) throw new PublishRefusal(NOT_PERSISTED);
1033
1081
  return fresh;
1034
1082
  }
1083
+ async function verifyEnvCredential(cred) {
1084
+ const identity = await fetchWhoami(cred.token);
1085
+ const claimed = cred.handle;
1086
+ if (claimed !== null && claimed.toLowerCase() !== identity.handle?.toLowerCase()) {
1087
+ const shown = `YMMV_HANDLE is "${sanitizeValue(claimed)}"`;
1088
+ throw new Error(
1089
+ identity.handle === null ? `${shown} but the YMMV_TOKEN account has no handle bound.` : `${shown} but YMMV_TOKEN belongs to "${identity.handle}". Fix or unset YMMV_HANDLE.`
1090
+ );
1091
+ }
1092
+ return { ...cred, handle: identity.handle, github_id: identity.github_id };
1093
+ }
1035
1094
  async function ensureLogin() {
1036
1095
  const existing = await loadCredential();
1037
- if (existing) return existing;
1096
+ if (existing) return existing.source === "env" ? verifyEnvCredential(existing) : existing;
1038
1097
  await login();
1039
1098
  const fresh = await loadCredential();
1040
1099
  if (!fresh) throw new Error(NOT_PERSISTED);
@@ -1054,11 +1113,13 @@ async function publishProfile(profile, expected) {
1054
1113
  },
1055
1114
  BASE
1056
1115
  );
1057
- let cred = await loadCredential();
1116
+ assertVerified(expected);
1117
+ let cred = expected.source === "env" ? expected : await loadCredential();
1058
1118
  if (!cred) {
1059
1119
  console.log(message("Not logged in. Logging in to publish."));
1060
1120
  cred = await loginOrRefuse();
1061
1121
  }
1122
+ assertVerified(cred);
1062
1123
  const idDrifted = expected.source === "file" && (expected.github_id === null ? cred.github_id !== null : cred.github_id !== expected.github_id);
1063
1124
  if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase() || idDrifted) {
1064
1125
  throw new PublishRefusal(
@@ -1069,7 +1130,11 @@ async function publishProfile(profile, expected) {
1069
1130
  if (res.status === 401 || res.status === 409) {
1070
1131
  if (cred.source === "env") {
1071
1132
  throw new PublishRefusal(
1072
- 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`."
1133
+ res.status === 401 ? ENV_TOKEN_REJECTED_MINT_AGAIN : (
1134
+ // The handle came from whoami moments ago, so a 409 means the bind changed in between
1135
+ // (a re-login on another machine after a GitHub rename). A fresh run looks it up again.
1136
+ "The server no longer accepts this handle for the YMMV_TOKEN account. Re-run the command."
1137
+ )
1073
1138
  );
1074
1139
  }
1075
1140
  const was401 = res.status === 401;
@@ -1143,6 +1208,7 @@ async function fetchProfileJson(handle) {
1143
1208
  return parseProfile(await res.json());
1144
1209
  }
1145
1210
  async function deleteProfile(cred) {
1211
+ assertVerified(cred);
1146
1212
  const res = await safeFetch(
1147
1213
  `${BASE}/api/v1/profile`,
1148
1214
  {
@@ -1529,16 +1595,16 @@ function requireHandle(cred) {
1529
1595
  if (cred.handle) return cred.handle;
1530
1596
  console.error(
1531
1597
  message(
1532
- 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."
1598
+ cred.source === "env" ? "The account behind YMMV_TOKEN has no handle bound. Run `ymmv login` on an interactive machine, as the same GitHub account, to rebind it. If that GitHub username is a reserved word, rename on GitHub first." : "Your GitHub username is a reserved word, so no handle is bound. Rename on GitHub, then run `ymmv login` again."
1533
1599
  )
1534
1600
  );
1535
1601
  process.exitCode = 1;
1536
1602
  return null;
1537
1603
  }
1538
- function assertHandleUnchanged(existing, handle) {
1604
+ function assertHandleUnchanged(existing, cred, handle) {
1539
1605
  if (existing && existing.handle.toLowerCase() !== handle.toLowerCase()) {
1540
1606
  throw new Error(
1541
- `This login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}". Run \`ymmv login\` to refresh, then retry.`
1607
+ `This login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}". ${cred.source === "env" ? "Re-run the command." : "Run `ymmv login` to refresh, then retry."}`
1542
1608
  );
1543
1609
  }
1544
1610
  }
@@ -1601,7 +1667,7 @@ async function publish(io) {
1601
1667
  }
1602
1668
  });
1603
1669
  const existing = await fetchProfileJson(handle);
1604
- assertHandleUnchanged(existing, handle);
1670
+ assertHandleUnchanged(existing, cred, handle);
1605
1671
  const defaults = buildDefaults(existing, detected);
1606
1672
  const carried = unknownEntries(existing);
1607
1673
  const extras = existing?.extras ?? [];
@@ -1698,7 +1764,26 @@ async function view(handle) {
1698
1764
  console.log(notFound(handle, c, BASE));
1699
1765
  return;
1700
1766
  }
1701
- const cred = await loadToken();
1767
+ const plainCard = (note, wrap = true) => {
1768
+ console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
1769
+ if (note) {
1770
+ const codes = palette(c);
1771
+ console.error(message(`${codes.faint}${wrap ? `(${note})` : note}${codes.reset}`));
1772
+ }
1773
+ };
1774
+ let cred = await loadCredential();
1775
+ if (cred?.source === "env") {
1776
+ try {
1777
+ cred = await verifyEnvCredential(cred);
1778
+ } catch (e) {
1779
+ plainCard(`No diff: ${displayError(e)}`, false);
1780
+ return;
1781
+ }
1782
+ if (cred.handle === null) {
1783
+ plainCard("no diff: the account behind YMMV_TOKEN has no handle bound");
1784
+ return;
1785
+ }
1786
+ }
1702
1787
  if (cred?.handle) {
1703
1788
  let mineFailed = false;
1704
1789
  const mine = await fetchProfileJson(cred.handle).catch(() => {
@@ -1712,24 +1797,23 @@ async function view(handle) {
1712
1797
  return;
1713
1798
  }
1714
1799
  if (!mine) {
1715
- console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
1716
1800
  if (mineFailed) {
1717
- const codes = palette(c);
1718
- console.error(message(`${codes.faint}(couldn't load your profile to diff)${codes.reset}`));
1801
+ plainCard("couldn't load your profile to diff");
1719
1802
  } else {
1803
+ plainCard();
1720
1804
  console.log(nudge(c));
1721
1805
  }
1722
1806
  return;
1723
1807
  }
1724
1808
  }
1725
- console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
1809
+ plainCard();
1726
1810
  }
1727
1811
  async function runSet(target) {
1728
1812
  const cred = await ensureLogin();
1729
1813
  const handle = requireHandle(cred);
1730
1814
  if (!handle) return;
1731
1815
  const existing = await fetchProfileJson(handle);
1732
- assertHandleUnchanged(existing, handle);
1816
+ assertHandleUnchanged(existing, cred, handle);
1733
1817
  const { entries, extras } = applySet(existing, target);
1734
1818
  if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
1735
1819
  console.error(
@@ -1749,7 +1833,7 @@ async function runUnset(target) {
1749
1833
  const handle = requireHandle(cred);
1750
1834
  if (!handle) return;
1751
1835
  const existing = await fetchProfileJson(handle);
1752
- assertHandleUnchanged(existing, handle);
1836
+ assertHandleUnchanged(existing, cred, handle);
1753
1837
  if (!existing) {
1754
1838
  console.log(message("No profile yet. Run `ymmv` to publish one."));
1755
1839
  return;
@@ -1769,7 +1853,7 @@ async function runUnset(target) {
1769
1853
  }
1770
1854
  async function runDelete(io) {
1771
1855
  const cred = await ensureLogin();
1772
- const target = cred.source === "env" ? "the profile bound to YMMV_TOKEN" : cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
1856
+ const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : cred.source === "env" ? "the profile bound to YMMV_TOKEN" : "your profile";
1773
1857
  if (!io.yes) {
1774
1858
  if (!io.interactive || !io.prompter) {
1775
1859
  console.error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {