ymmv-cli 0.9.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 +20 -11
  2. package/dist/cli.js +200 -47
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -68,15 +68,20 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
68
68
  ## Environment variables
69
69
 
70
70
  - `NO_COLOR` disables color output (and `FORCE_COLOR=0`/`false` force-disables it).
71
- - `YMMV_API` points the CLI at a different Worker (development/staging). Bare origin only.
71
+ - `YMMV_API` points the CLI at a different Worker (development/staging). Bare origin only. That
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, and `YMMV_TOKEN` needs
74
+ the server's account lookup.
72
75
  - `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
73
76
  the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
74
- `ymmv login` / `ymmv logout` keep acting on the stored login. Viewing (`ymmv <handle>`) also
75
- keeps using the stored login for the you-side of a diff. The token is sent to the server
76
- `YMMV_API` selects, so set the two together.
77
- - `YMMV_HANDLE` names the GitHub username `YMMV_TOKEN` belongs to. Required for `ymmv -y` and
78
- `ymmv set`/`unset` under an env token (there is no server lookup for it); ignored without
79
- `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`.
80
85
  - `YMMV_NO_UPDATE_CHECK` disables the startup check for newer releases (the ecosystem-standard
81
86
  `NO_UPDATE_NOTIFIER` works too). The check is also off automatically under `CI`, in pipes,
82
87
  and in dev builds; it never blocks or fails a command.
@@ -89,7 +94,8 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
89
94
  2. Copy the `token` value from the token file:
90
95
  `~/.config/ymmv/token.json` (Linux), `~/Library/Preferences/ymmv/token.json` (macOS),
91
96
  `%APPDATA%\ymmv\Config\token.json` (Windows).
92
- 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.
93
99
  4. Run `npx ymmv-cli@latest -y` in the job.
94
100
 
95
101
  Two things to know:
@@ -98,9 +104,12 @@ Two things to know:
98
104
  runs on. Values you already published always win, but curated keys you have never set get the
99
105
  CI runner's detected values (its OS, shell, and so on). For targeted updates from CI, prefer
100
106
  `ymmv set <key> <value>`.
101
- - A rejected or revoked `YMMV_TOKEN` fails with an error naming the variable; nothing falls back
102
- to an interactive login, and the stored login file on the runner (if any) is left untouched.
103
- `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.
104
113
 
105
114
  ## License
106
115
 
package/dist/cli.js CHANGED
@@ -259,6 +259,9 @@ function diff(mine, theirs) {
259
259
 
260
260
  // ../shared/dist/github.js
261
261
  var GITHUB_CLIENT_ID = "Ov23liMoD29eizQcN1KZ";
262
+ function isGithubId(x) {
263
+ return typeof x === "number" && Number.isSafeInteger(x) && x > 0;
264
+ }
262
265
 
263
266
  // ../shared/dist/types.js
264
267
  var SCHEMA_VERSION = 1;
@@ -390,10 +393,7 @@ var CTRL_RE = new RegExp(
390
393
  `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
391
394
  "g"
392
395
  );
393
- var BIDI_RE = new RegExp(
394
- `[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
395
- "g"
396
- );
396
+ var BIDI_RE = new RegExp("\\p{Bidi_Control}", "gu");
397
397
  function sanitizeValue(value) {
398
398
  return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
399
399
  }
@@ -545,6 +545,7 @@ function notFound(handle, color, base) {
545
545
 
546
546
  // src/http.ts
547
547
  var REQUEST_TIMEOUT_MS = 3e4;
548
+ var REVOKE_CAP_MS = 5e3;
548
549
  var TIMEOUT_TEXT = "request timed out";
549
550
  function isTimeoutError(err) {
550
551
  return err instanceof Error && (err.name === "TimeoutError" || err.cause instanceof Error && err.cause.name === "TimeoutError");
@@ -683,6 +684,23 @@ async function bodyJson(res) {
683
684
  return null;
684
685
  }
685
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
+ }
698
+ var MintRejected = class extends Error {
699
+ constructor(msg) {
700
+ super(msg);
701
+ this.name = "MintRejected";
702
+ }
703
+ };
686
704
  async function mintYmmvToken(accessToken) {
687
705
  const res = await safeFetch(
688
706
  `${BASE}/api/v1/auth/token`,
@@ -724,19 +742,61 @@ async function mintYmmvToken(accessToken) {
724
742
  throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
725
743
  }
726
744
  const data = await bodyJson(res);
727
- if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
745
+ const unexpected = `Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`;
746
+ if (!data) throw new Error(unexpected);
747
+ const token = typeof data.token === "string" && data.token.length > 0 ? data.token : null;
748
+ const identity = parseIdentity(data);
749
+ if (token === null || identity === null) {
750
+ let revokeFailed = false;
751
+ if (token !== null) {
752
+ await revokeYmmvToken(token, AbortSignal.timeout(REVOKE_CAP_MS)).catch(() => {
753
+ revokeFailed = true;
754
+ });
755
+ }
756
+ throw new MintRejected(
757
+ revokeFailed ? `${unexpected} The login the server minted could not be revoked.` : unexpected
758
+ );
759
+ }
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
+ }
728
780
  throw new Error(
729
- `Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`
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
+ )
730
785
  );
731
786
  }
732
- return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
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;
733
792
  }
734
- async function revokeYmmvToken(token) {
793
+ async function revokeYmmvToken(token, signal) {
735
794
  const res = await safeFetch(
736
795
  `${BASE}/api/v1/auth/logout`,
737
796
  {
738
797
  method: "POST",
739
798
  headers: { authorization: `Bearer ${token}` },
799
+ signal,
740
800
  // Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete
741
801
  // the local file while the server token stays live). Same guard as mint + publish/delete.
742
802
  redirect: "manual"
@@ -769,7 +829,12 @@ async function saveToken(data) {
769
829
  if (process.platform !== "win32") await chmod(dir, 448).catch(() => {
770
830
  });
771
831
  const tmp = `${path}.${randomUUID()}.tmp`;
772
- const stored = { base: BASE, token: data.token, handle: data.handle };
832
+ const stored = {
833
+ base: BASE,
834
+ token: data.token,
835
+ handle: data.handle,
836
+ github_id: data.github_id
837
+ };
773
838
  try {
774
839
  await writeFile(tmp, JSON.stringify(stored), { mode: 384 });
775
840
  if (process.platform !== "win32") await chmod(tmp, 384);
@@ -790,10 +855,17 @@ async function readTokenFile() {
790
855
  }
791
856
  async function loadToken() {
792
857
  const parsed = await readTokenFile();
793
- if (!parsed || parsed.base !== BASE || typeof parsed.token !== "string" || parsed.token === "" || parsed.handle !== null && typeof parsed.handle !== "string") {
858
+ const rawId = parsed?.github_id;
859
+ const idOk = rawId === void 0 || rawId === null || isGithubId(rawId);
860
+ if (!parsed || parsed.base !== BASE || typeof parsed.token !== "string" || parsed.token === "" || parsed.handle !== null && typeof parsed.handle !== "string" || !idOk) {
794
861
  return null;
795
862
  }
796
- return parsed;
863
+ return {
864
+ base: parsed.base,
865
+ token: parsed.token,
866
+ handle: parsed.handle === "" ? null : parsed.handle,
867
+ github_id: isGithubId(rawId) ? rawId : null
868
+ };
797
869
  }
798
870
  async function peekCredential() {
799
871
  const parsed = await readTokenFile();
@@ -802,7 +874,13 @@ async function peekCredential() {
802
874
  async function loadCredential() {
803
875
  const envToken = process.env.YMMV_TOKEN || "";
804
876
  if (envToken !== "") {
805
- return { base: BASE, token: envToken, handle: process.env.YMMV_HANDLE || null, source: "env" };
877
+ return {
878
+ base: BASE,
879
+ token: envToken,
880
+ handle: process.env.YMMV_HANDLE || null,
881
+ github_id: null,
882
+ source: "env"
883
+ };
806
884
  }
807
885
  const stored = await loadToken();
808
886
  return stored ? { ...stored, source: "file" } : null;
@@ -950,16 +1028,16 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
950
1028
  )
951
1029
  );
952
1030
  const accessToken = await pollForToken(dc, deps);
953
- const { token, handle } = await mintYmmvToken(accessToken);
1031
+ const minted = await mintYmmvToken(accessToken);
954
1032
  const replaced = await peekCredential();
955
1033
  try {
956
- await saveToken({ token, handle });
1034
+ await saveToken(minted);
957
1035
  } catch (e) {
958
- await revokeYmmvToken(token).catch(() => {
1036
+ await revokeYmmvToken(minted.token).catch(() => {
959
1037
  });
960
1038
  throw e;
961
1039
  }
962
- if (replaced && replaced.base === BASE && replaced.token !== token) {
1040
+ if (replaced && replaced.base === BASE && replaced.token !== minted.token) {
963
1041
  try {
964
1042
  await revokeYmmvToken(replaced.token);
965
1043
  } catch {
@@ -968,7 +1046,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
968
1046
  }
969
1047
  console.log(
970
1048
  message(
971
- handle ? `Logged in as ${handle}.` : "Logged in. No handle bound (your GitHub username is a reserved word)."
1049
+ minted.handle ? `Logged in as ${minted.handle}.` : "Logged in. No handle bound (your GitHub username is a reserved word)."
972
1050
  )
973
1051
  );
974
1052
  }
@@ -980,19 +1058,48 @@ var PublishRefusal = class extends Error {
980
1058
  this.name = "PublishRefusal";
981
1059
  }
982
1060
  };
983
- var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (revoked or expired).";
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
+ }
984
1069
  async function rateLimitMessage(res) {
985
1070
  return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
986
1071
  }
1072
+ async function loginOrRefuse() {
1073
+ try {
1074
+ await login();
1075
+ } catch (e) {
1076
+ if (e instanceof MintRejected) throw new PublishRefusal(e.message);
1077
+ throw e;
1078
+ }
1079
+ const fresh = await loadCredential();
1080
+ if (!fresh) throw new PublishRefusal(NOT_PERSISTED);
1081
+ return fresh;
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
+ }
987
1094
  async function ensureLogin() {
988
1095
  const existing = await loadCredential();
989
- if (existing) return existing;
1096
+ if (existing) return existing.source === "env" ? verifyEnvCredential(existing) : existing;
990
1097
  await login();
991
1098
  const fresh = await loadCredential();
992
- if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
1099
+ if (!fresh) throw new Error(NOT_PERSISTED);
993
1100
  return fresh;
994
1101
  }
995
- async function publishProfile(profile) {
1102
+ async function publishProfile(profile, expected) {
996
1103
  const send = (c) => safeFetch(
997
1104
  `${BASE}/api/v1/profile`,
998
1105
  {
@@ -1006,8 +1113,15 @@ async function publishProfile(profile) {
1006
1113
  },
1007
1114
  BASE
1008
1115
  );
1009
- let cred = await ensureLogin();
1010
- if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
1116
+ assertVerified(expected);
1117
+ let cred = expected.source === "env" ? expected : await loadCredential();
1118
+ if (!cred) {
1119
+ console.log(message("Not logged in. Logging in to publish."));
1120
+ cred = await loginOrRefuse();
1121
+ }
1122
+ assertVerified(cred);
1123
+ const idDrifted = expected.source === "file" && (expected.github_id === null ? cred.github_id !== null : cred.github_id !== expected.github_id);
1124
+ if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase() || idDrifted) {
1011
1125
  throw new PublishRefusal(
1012
1126
  "The stored login changed while this command was running. Re-run it under the current account."
1013
1127
  );
@@ -1016,23 +1130,43 @@ async function publishProfile(profile) {
1016
1130
  if (res.status === 401 || res.status === 409) {
1017
1131
  if (cred.source === "env") {
1018
1132
  throw new PublishRefusal(
1019
- 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
+ )
1020
1138
  );
1021
1139
  }
1022
1140
  const was401 = res.status === 401;
1141
+ const before = cred;
1023
1142
  console.log(
1024
1143
  message(
1025
1144
  was401 ? "Session expired. Logging in again to retry the publish." : "The server no longer recognizes your handle. Logging in again to retry the publish."
1026
1145
  )
1027
1146
  );
1028
1147
  if (was401) await deleteToken();
1029
- await login();
1030
- cred = await ensureLogin();
1031
- if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
1032
- const bound = sanitizeValue(cred.handle ?? "");
1033
- throw new PublishRefusal(
1034
- 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.`
1035
- );
1148
+ cred = await loginOrRefuse();
1149
+ const known = before.github_id !== null;
1150
+ const idChanged = known && cred.github_id !== before.github_id;
1151
+ const sameHandle = (cred.handle ?? "").toLowerCase() === profile.handle.toLowerCase();
1152
+ if (!sameHandle || idChanged) {
1153
+ const mine = sanitizeValue(profile.handle);
1154
+ const bound = cred.handle === null ? null : sanitizeValue(cred.handle);
1155
+ const reRun = "Nothing was published. Re-run under the account you meant.";
1156
+ const otherAccount = `The re-login bound a different account ("${bound}", not "${mine}"). ${reRun}`;
1157
+ if (idChanged) {
1158
+ throw new PublishRefusal(
1159
+ sameHandle ? `The handle "${mine}" now belongs to a different GitHub account. ${reRun}` : bound === null ? `The re-login bound a different GitHub account. ${reRun}` : otherAccount
1160
+ );
1161
+ }
1162
+ if (bound === null) {
1163
+ throw new PublishRefusal(
1164
+ "Your login no longer binds a handle (your GitHub username is a reserved word). Nothing was published."
1165
+ );
1166
+ }
1167
+ const rebound = `Your login now binds "${bound}". Nothing was published. Re-run the command to publish under it.`;
1168
+ if (known) throw new PublishRefusal(rebound);
1169
+ throw new PublishRefusal(was401 ? otherAccount : rebound);
1036
1170
  }
1037
1171
  res = await send(cred);
1038
1172
  if (res.status === 401) throw new PublishRefusal("Authentication failed. Run `ymmv login`.");
@@ -1074,6 +1208,7 @@ async function fetchProfileJson(handle) {
1074
1208
  return parseProfile(await res.json());
1075
1209
  }
1076
1210
  async function deleteProfile(cred) {
1211
+ assertVerified(cred);
1077
1212
  const res = await safeFetch(
1078
1213
  `${BASE}/api/v1/profile`,
1079
1214
  {
@@ -1460,16 +1595,16 @@ function requireHandle(cred) {
1460
1595
  if (cred.handle) return cred.handle;
1461
1596
  console.error(
1462
1597
  message(
1463
- 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."
1464
1599
  )
1465
1600
  );
1466
1601
  process.exitCode = 1;
1467
1602
  return null;
1468
1603
  }
1469
- function assertHandleUnchanged(existing, handle) {
1604
+ function assertHandleUnchanged(existing, cred, handle) {
1470
1605
  if (existing && existing.handle.toLowerCase() !== handle.toLowerCase()) {
1471
1606
  throw new Error(
1472
- `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."}`
1473
1608
  );
1474
1609
  }
1475
1610
  }
@@ -1532,7 +1667,7 @@ async function publish(io) {
1532
1667
  }
1533
1668
  });
1534
1669
  const existing = await fetchProfileJson(handle);
1535
- assertHandleUnchanged(existing, handle);
1670
+ assertHandleUnchanged(existing, cred, handle);
1536
1671
  const defaults = buildDefaults(existing, detected);
1537
1672
  const carried = unknownEntries(existing);
1538
1673
  const extras = existing?.extras ?? [];
@@ -1576,7 +1711,7 @@ async function publish(io) {
1576
1711
  }
1577
1712
  const entries = assemble();
1578
1713
  showCard(entries);
1579
- printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1714
+ printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1580
1715
  return;
1581
1716
  }
1582
1717
  try {
@@ -1592,7 +1727,7 @@ async function publish(io) {
1592
1727
  );
1593
1728
  if (ans === "y") {
1594
1729
  try {
1595
- printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1730
+ printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1596
1731
  return;
1597
1732
  } catch (e) {
1598
1733
  if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
@@ -1629,7 +1764,26 @@ async function view(handle) {
1629
1764
  console.log(notFound(handle, c, BASE));
1630
1765
  return;
1631
1766
  }
1632
- 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
+ }
1633
1787
  if (cred?.handle) {
1634
1788
  let mineFailed = false;
1635
1789
  const mine = await fetchProfileJson(cred.handle).catch(() => {
@@ -1643,24 +1797,23 @@ async function view(handle) {
1643
1797
  return;
1644
1798
  }
1645
1799
  if (!mine) {
1646
- console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
1647
1800
  if (mineFailed) {
1648
- const codes = palette(c);
1649
- console.error(message(`${codes.faint}(couldn't load your profile to diff)${codes.reset}`));
1801
+ plainCard("couldn't load your profile to diff");
1650
1802
  } else {
1803
+ plainCard();
1651
1804
  console.log(nudge(c));
1652
1805
  }
1653
1806
  return;
1654
1807
  }
1655
1808
  }
1656
- console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
1809
+ plainCard();
1657
1810
  }
1658
1811
  async function runSet(target) {
1659
1812
  const cred = await ensureLogin();
1660
1813
  const handle = requireHandle(cred);
1661
1814
  if (!handle) return;
1662
1815
  const existing = await fetchProfileJson(handle);
1663
- assertHandleUnchanged(existing, handle);
1816
+ assertHandleUnchanged(existing, cred, handle);
1664
1817
  const { entries, extras } = applySet(existing, target);
1665
1818
  if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
1666
1819
  console.error(
@@ -1671,7 +1824,7 @@ async function runSet(target) {
1671
1824
  process.exitCode = 1;
1672
1825
  return;
1673
1826
  }
1674
- const res = await publishProfile(newProfile(handle, entries, extras));
1827
+ const res = await publishProfile(newProfile(handle, entries, extras), cred);
1675
1828
  const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
1676
1829
  console.log(message(`${line}${pagePointer(res.handle)}`));
1677
1830
  }
@@ -1680,7 +1833,7 @@ async function runUnset(target) {
1680
1833
  const handle = requireHandle(cred);
1681
1834
  if (!handle) return;
1682
1835
  const existing = await fetchProfileJson(handle);
1683
- assertHandleUnchanged(existing, handle);
1836
+ assertHandleUnchanged(existing, cred, handle);
1684
1837
  if (!existing) {
1685
1838
  console.log(message("No profile yet. Run `ymmv` to publish one."));
1686
1839
  return;
@@ -1694,13 +1847,13 @@ async function runUnset(target) {
1694
1847
  );
1695
1848
  return;
1696
1849
  }
1697
- const res = await publishProfile(newProfile(handle, entries, extras));
1850
+ const res = await publishProfile(newProfile(handle, entries, extras), cred);
1698
1851
  const line = target.kind === "curated" ? `Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").` : `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`;
1699
1852
  console.log(message(`${line}${pagePointer(res.handle)}`));
1700
1853
  }
1701
1854
  async function runDelete(io) {
1702
1855
  const cred = await ensureLogin();
1703
- 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";
1704
1857
  if (!io.yes) {
1705
1858
  if (!io.interactive || !io.prompter) {
1706
1859
  console.error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.9.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": {