ymmv-cli 0.10.0 → 0.11.1

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 +21 -12
  2. package/dist/cli.js +279 -57
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -69,16 +69,21 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
69
69
 
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
- 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.
72
+ Worker must be deployed with or after this CLI release: `ymmv login` requires every field the
73
+ login response carries (the account id, and on a re-login the server's answer to retiring the
74
+ previous token) and refuses (revoking what it minted) otherwise, `YMMV_TOKEN` needs the
75
+ server's account lookup, and `ymmv`, `ymmv set`, and `ymmv unset` need the profile lookup they
76
+ read from before writing.
74
77
  - `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
75
78
  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`.
79
+ `ymmv login` / `ymmv logout` keep acting on the stored login. The CLI asks the server which
80
+ account the token belongs to, so every command, including the you-side of a `ymmv <handle>`
81
+ diff, runs as that account. The token is sent to the server `YMMV_API` selects, so set the
82
+ two together.
83
+ - `YMMV_HANDLE` is optional. When set, it must name the GitHub username `YMMV_TOKEN` belongs
84
+ to: if the server reports a different account, `ymmv -y`, `ymmv set`/`unset`, and
85
+ `ymmv delete` refuse before sending anything, and `ymmv <handle>` shows the profile without
86
+ a diff. Ignored without `YMMV_TOKEN`.
82
87
  - `YMMV_NO_UPDATE_CHECK` disables the startup check for newer releases (the ecosystem-standard
83
88
  `NO_UPDATE_NOTIFIER` works too). The check is also off automatically under `CI`, in pipes,
84
89
  and in dev builds; it never blocks or fails a command.
@@ -91,7 +96,8 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
91
96
  2. Copy the `token` value from the token file:
92
97
  `~/.config/ymmv/token.json` (Linux), `~/Library/Preferences/ymmv/token.json` (macOS),
93
98
  `%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.
99
+ 3. Set it as a CI secret named `YMMV_TOKEN`. Setting `YMMV_HANDLE` to your GitHub username is
100
+ optional, and makes the job fail if the secret ever holds another account's token.
95
101
  4. Run `npx ymmv-cli@latest -y` in the job.
96
102
 
97
103
  Two things to know:
@@ -100,9 +106,12 @@ Two things to know:
100
106
  runs on. Values you already published always win, but curated keys you have never set get the
101
107
  CI runner's detected values (its OS, shell, and so on). For targeted updates from CI, prefer
102
108
  `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`.
109
+ - A rejected or revoked `YMMV_TOKEN` fails `ymmv -y`, `ymmv set`/`unset`, and `ymmv delete` with
110
+ an error naming the variable; nothing falls back to an interactive login, and the stored login
111
+ file on the runner (if any) is left untouched. `ymmv <handle>` still shows the profile, with the
112
+ reason there is no diff on stderr, and exits 0.
113
+ `ymmv delete` acts on the account the token is bound to and names that account's page when
114
+ it asks for confirmation.
106
115
 
107
116
  ## License
108
117
 
package/dist/cli.js CHANGED
@@ -257,6 +257,34 @@ function diff(mine, theirs) {
257
257
  };
258
258
  }
259
259
 
260
+ // ../shared/dist/display-url.js
261
+ var PARSER_STRIPS = /[\t\n\r]/g;
262
+ var HTTP_RE = /^https?:[/\\]*([^/?#\\]*)([/?#\\][\s\S]*)?$/i;
263
+ function trimC0(s) {
264
+ let start = 0;
265
+ let end = s.length;
266
+ while (start < end && s.charCodeAt(start) <= 32)
267
+ start++;
268
+ while (end > start && s.charCodeAt(end - 1) <= 32)
269
+ end--;
270
+ return s.slice(start, end);
271
+ }
272
+ function displayUrl(value) {
273
+ const raw = value.trim();
274
+ const input = trimC0(raw.replace(PARSER_STRIPS, ""));
275
+ const m = HTTP_RE.exec(input);
276
+ if (!m)
277
+ return raw;
278
+ let url;
279
+ try {
280
+ url = new URL(input);
281
+ } catch {
282
+ return raw;
283
+ }
284
+ const prefix = url.protocol === "http:" ? "http://" : "";
285
+ return `${prefix}${url.host}${m[2] ?? ""}`;
286
+ }
287
+
260
288
  // ../shared/dist/github.js
261
289
  var GITHUB_CLIENT_ID = "Ov23liMoD29eizQcN1KZ";
262
290
  function isGithubId(x) {
@@ -410,12 +438,10 @@ function colorEnabled() {
410
438
  }
411
439
  var OSC = `${ESC}]`;
412
440
  var ST = `${ESC}\\`;
413
- function displayUrl(value) {
414
- return value.trim().replace(/^https:\/\/(?=.)/i, "");
415
- }
416
441
  var HTTP_URL_RE = /^https?:\/\/\S+$/i;
417
442
  function isHttpUrl(value) {
418
- return HTTP_URL_RE.test(value.trim());
443
+ const t = value.trim();
444
+ return HTTP_URL_RE.test(t) && URL.canParse(t);
419
445
  }
420
446
  var NO_OSC8_TERMS = /* @__PURE__ */ new Set(["linux", "dumb"]);
421
447
  function link(url, color, term = process.env.TERM, label) {
@@ -684,19 +710,34 @@ async function bodyJson(res) {
684
710
  return null;
685
711
  }
686
712
  }
713
+ var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (invalid or revoked).";
714
+ var ENV_TOKEN_REJECTED_MINT_AGAIN = `${ENV_TOKEN_REJECTED} Mint a new one with \`ymmv login\` on an interactive machine and update YMMV_TOKEN.`;
715
+ function parseIdentity(data) {
716
+ if (typeof data !== "object" || data === null) return null;
717
+ const { handle: rawHandle, github_id: id } = data;
718
+ const handle = rawHandle === null ? null : typeof rawHandle === "string" ? sanitizeValue(rawHandle) : void 0;
719
+ if (handle === void 0 || handle !== null && !isValidHandle(handle) || !isGithubId(id)) {
720
+ return null;
721
+ }
722
+ return { github_id: id, handle };
723
+ }
724
+ function serverBehindHint() {
725
+ return process.env.YMMV_API ? "Point YMMV_API at an up-to-date server." : "The server is behind this CLI release; try again later.";
726
+ }
687
727
  var MintRejected = class extends Error {
688
728
  constructor(msg) {
689
729
  super(msg);
690
730
  this.name = "MintRejected";
691
731
  }
692
732
  };
693
- async function mintYmmvToken(accessToken) {
733
+ async function mintYmmvToken(accessToken, revoke) {
694
734
  const res = await safeFetch(
695
735
  `${BASE}/api/v1/auth/token`,
696
736
  {
697
737
  method: "POST",
698
738
  headers: { "content-type": "application/json" },
699
- body: JSON.stringify({ access_token: accessToken }),
739
+ // JSON.stringify drops an undefined `revoke`: no key on the wire when there is nothing to retire.
740
+ body: JSON.stringify({ access_token: accessToken, revoke }),
700
741
  // Never follow a redirect: a 30x must fail (the existing `!res.ok` guard rejects the resulting
701
742
  // opaqueredirect), not re-POST the GitHub access_token to the redirect target or read a
702
743
  // redirected 200 as a successful mint. Mirrors publish/delete in api.ts.
@@ -731,22 +772,57 @@ async function mintYmmvToken(accessToken) {
731
772
  throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
732
773
  }
733
774
  const data = await bodyJson(res);
734
- const unexpected = `Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`;
775
+ const unexpected = `Unexpected response from ${BASE}. Nothing was saved` + (revoke === void 0 ? "" : " (the previous login on this machine may have been signed out)") + "; run `ymmv login` again.";
735
776
  if (!data) throw new Error(unexpected);
736
777
  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)) {
778
+ const identity = parseIdentity(data);
779
+ const revoked = typeof data.revoked === "boolean" ? data.revoked : void 0;
780
+ const behind = token !== null && identity !== null && revoke !== void 0 && revoked === void 0 ? `${BASE} did not retire the previous login. ${serverBehindHint()} Nothing was saved; to log in anyway, run \`ymmv logout\` first.` : null;
781
+ if (token === null || identity === null || behind !== null) {
739
782
  let revokeFailed = false;
740
783
  if (token !== null) {
741
784
  await revokeYmmvToken(token, AbortSignal.timeout(REVOKE_CAP_MS)).catch(() => {
742
785
  revokeFailed = true;
743
786
  });
744
787
  }
788
+ const refusal = behind ?? unexpected;
745
789
  throw new MintRejected(
746
- revokeFailed ? `${unexpected} The login the server minted could not be revoked.` : unexpected
790
+ revokeFailed ? `${refusal} The login the server minted could not be revoked.` : refusal
747
791
  );
748
792
  }
749
- return { token, handle, github_id: data.github_id };
793
+ return { token, ...identity };
794
+ }
795
+ function missingRouteError(missing) {
796
+ return new Error(`${BASE} has no ${missing}. ${serverBehindHint()}`);
797
+ }
798
+ async function fetchWhoami(token) {
799
+ const res = await safeFetch(
800
+ `${BASE}/api/v1/auth/whoami`,
801
+ {
802
+ headers: { authorization: `Bearer ${token}` },
803
+ // Never follow a redirect: the bearer must not travel to a redirect target, and a 30x→200
804
+ // must not read as a verified identity. Same guard as mint, logout, publish, and delete.
805
+ redirect: "manual"
806
+ },
807
+ BASE
808
+ );
809
+ if (!res.ok) {
810
+ if (res.status === 401) throw new Error(ENV_TOKEN_REJECTED_MINT_AGAIN);
811
+ if (res.status === 404) {
812
+ throw missingRouteError("identity lookup for YMMV_TOKEN");
813
+ }
814
+ throw new Error(
815
+ withRetryHint(
816
+ await serverMessage(res) ?? (res.status === 429 ? "rate limited, too many requests" : `The server couldn't look up your token (${res.status}). Try again shortly.`),
817
+ res
818
+ )
819
+ );
820
+ }
821
+ const identity = parseIdentity(await bodyJson(res));
822
+ if (!identity) {
823
+ throw new Error(`Unexpected response from ${BASE}. Check YMMV_API, or try again shortly.`);
824
+ }
825
+ return identity;
750
826
  }
751
827
  async function revokeYmmvToken(token, signal) {
752
828
  const res = await safeFetch(
@@ -954,6 +1030,9 @@ async function pollForToken(dc, deps = {}) {
954
1030
  }
955
1031
  throw new Error("Device code expired. Run `ymmv login` again.");
956
1032
  }
1033
+ function retirable(cred) {
1034
+ return cred != null && cred.base === BASE && cred.token.trim() !== "";
1035
+ }
957
1036
  async function login(deps = {}) {
958
1037
  if (!process.stdin.isTTY) {
959
1038
  throw new Error(
@@ -986,18 +1065,23 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
986
1065
  )
987
1066
  );
988
1067
  const accessToken = await pollForToken(dc, deps);
989
- const minted = await mintYmmvToken(accessToken);
990
- const replaced = await peekCredential();
1068
+ const before = await peekCredential();
1069
+ const revoke = retirable(before) ? before.token : void 0;
1070
+ const minted = await mintYmmvToken(accessToken, revoke);
1071
+ const after = await peekCredential();
991
1072
  try {
992
1073
  await saveToken(minted);
993
1074
  } catch (e) {
994
1075
  await revokeYmmvToken(minted.token).catch(() => {
1076
+ console.error(
1077
+ message(`${c.faint}(the login the server minted could not be revoked)${c.reset}`)
1078
+ );
995
1079
  });
996
1080
  throw e;
997
1081
  }
998
- if (replaced && replaced.base === BASE && replaced.token !== minted.token) {
1082
+ if (retirable(after) && after.token !== revoke && after.token !== minted.token) {
999
1083
  try {
1000
- await revokeYmmvToken(replaced.token);
1084
+ await revokeYmmvToken(after.token);
1001
1085
  } catch {
1002
1086
  console.error(message(`${c.faint}(couldn't revoke the previous session's token)${c.reset}`));
1003
1087
  }
@@ -1016,8 +1100,20 @@ var PublishRefusal = class extends Error {
1016
1100
  this.name = "PublishRefusal";
1017
1101
  }
1018
1102
  };
1019
- var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (revoked or expired).";
1103
+ var ProfileChanged = class extends PublishRefusal {
1104
+ constructor() {
1105
+ super("Your profile changed since this command read it. Re-run the command.");
1106
+ this.name = "ProfileChanged";
1107
+ }
1108
+ };
1020
1109
  var NOT_PERSISTED = "Login did not persist a token. Run `ymmv login`.";
1110
+ function assertVerified(cred) {
1111
+ if (cred.source === "env" && cred.github_id === null) {
1112
+ throw new PublishRefusal(
1113
+ "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."
1114
+ );
1115
+ }
1116
+ }
1021
1117
  async function rateLimitMessage(res) {
1022
1118
  return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
1023
1119
  }
@@ -1032,20 +1128,35 @@ async function loginOrRefuse() {
1032
1128
  if (!fresh) throw new PublishRefusal(NOT_PERSISTED);
1033
1129
  return fresh;
1034
1130
  }
1131
+ async function verifyEnvCredential(cred) {
1132
+ const identity = await fetchWhoami(cred.token);
1133
+ const claimed = cred.handle;
1134
+ if (claimed !== null && claimed.toLowerCase() !== identity.handle?.toLowerCase()) {
1135
+ const shown = `YMMV_HANDLE is "${sanitizeValue(claimed)}"`;
1136
+ throw new Error(
1137
+ 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.`
1138
+ );
1139
+ }
1140
+ return { ...cred, handle: identity.handle, github_id: identity.github_id };
1141
+ }
1035
1142
  async function ensureLogin() {
1036
1143
  const existing = await loadCredential();
1037
- if (existing) return existing;
1144
+ if (existing) return existing.source === "env" ? verifyEnvCredential(existing) : existing;
1038
1145
  await login();
1039
1146
  const fresh = await loadCredential();
1040
1147
  if (!fresh) throw new Error(NOT_PERSISTED);
1041
1148
  return fresh;
1042
1149
  }
1043
- async function publishProfile(profile, expected) {
1150
+ async function publishProfile(profile, expected, opts = {}) {
1044
1151
  const send = (c) => safeFetch(
1045
1152
  `${BASE}/api/v1/profile`,
1046
1153
  {
1047
1154
  method: "POST",
1048
- headers: { "content-type": "application/json", authorization: `Bearer ${c.token}` },
1155
+ headers: {
1156
+ "content-type": "application/json",
1157
+ authorization: `Bearer ${c.token}`,
1158
+ ...opts.ifMatch ? { "if-match": opts.ifMatch } : {}
1159
+ },
1049
1160
  // Send the login-bound handle, never a caller-guessed one — the official client never claims
1050
1161
  // a handle it doesn't own.
1051
1162
  body: JSON.stringify({ ...profile, handle: c.handle ?? profile.handle }),
@@ -1054,11 +1165,13 @@ async function publishProfile(profile, expected) {
1054
1165
  },
1055
1166
  BASE
1056
1167
  );
1057
- let cred = await loadCredential();
1168
+ assertVerified(expected);
1169
+ let cred = expected.source === "env" ? expected : await loadCredential();
1058
1170
  if (!cred) {
1059
1171
  console.log(message("Not logged in. Logging in to publish."));
1060
1172
  cred = await loginOrRefuse();
1061
1173
  }
1174
+ assertVerified(cred);
1062
1175
  const idDrifted = expected.source === "file" && (expected.github_id === null ? cred.github_id !== null : cred.github_id !== expected.github_id);
1063
1176
  if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase() || idDrifted) {
1064
1177
  throw new PublishRefusal(
@@ -1069,7 +1182,11 @@ async function publishProfile(profile, expected) {
1069
1182
  if (res.status === 401 || res.status === 409) {
1070
1183
  if (cred.source === "env") {
1071
1184
  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`."
1185
+ res.status === 401 ? ENV_TOKEN_REJECTED_MINT_AGAIN : (
1186
+ // The handle came from whoami moments ago, so a 409 means the bind changed in between
1187
+ // (a re-login on another machine after a GitHub rename). A fresh run looks it up again.
1188
+ "The server no longer accepts this handle for the YMMV_TOKEN account. Re-run the command."
1189
+ )
1073
1190
  );
1074
1191
  }
1075
1192
  const was401 = res.status === 401;
@@ -1117,6 +1234,7 @@ async function publishProfile(profile, expected) {
1117
1234
  );
1118
1235
  }
1119
1236
  }
1237
+ if (res.status === 412) throw new ProfileChanged();
1120
1238
  if (res.status === 429) throw new Error(await rateLimitMessage(res));
1121
1239
  if (!res.ok) {
1122
1240
  const raw = await wireBody(res);
@@ -1142,7 +1260,42 @@ async function fetchProfileJson(handle) {
1142
1260
  }
1143
1261
  return parseProfile(await res.json());
1144
1262
  }
1263
+ var HEADER_SAFE_TAG = /^[!#-~]{1,128}$/;
1264
+ async function fetchOwnProfile(cred) {
1265
+ assertVerified(cred);
1266
+ const res = await safeFetch(
1267
+ `${BASE}/api/v1/profile`,
1268
+ {
1269
+ headers: { authorization: `Bearer ${cred.token}` },
1270
+ // Never follow a redirect: the bearer must not travel to a redirect target, and a 30x→200
1271
+ // must not read as a profile. Same guard as whoami, mint, logout, publish, and delete.
1272
+ redirect: "manual"
1273
+ },
1274
+ BASE
1275
+ );
1276
+ if (res.status === 401) {
1277
+ throw new Error(
1278
+ cred.source === "env" ? ENV_TOKEN_REJECTED_MINT_AGAIN : "Session expired. Run `ymmv login`, then re-run the command."
1279
+ );
1280
+ }
1281
+ if (res.status === 404) {
1282
+ const { slug } = wireErrorBody(await wireBody(res));
1283
+ if (slug === "not_found") return null;
1284
+ throw missingRouteError("profile lookup");
1285
+ }
1286
+ if (res.status === 429) throw new Error(await rateLimitMessage(res));
1287
+ if (!res.ok) {
1288
+ const raw = await wireBody(res);
1289
+ throw new Error(withRetryHint(`fetch failed: ${res.status} ${wireText(raw)}`, res));
1290
+ }
1291
+ const profile = parseProfile(await res.json());
1292
+ if (!HEADER_SAFE_TAG.test(profile.updated_at)) {
1293
+ throw new Error(`Unexpected response from ${BASE}. Check YMMV_API, or try again shortly.`);
1294
+ }
1295
+ return { profile, etag: `"${profile.updated_at}"` };
1296
+ }
1145
1297
  async function deleteProfile(cred) {
1298
+ assertVerified(cred);
1146
1299
  const res = await safeFetch(
1147
1300
  `${BASE}/api/v1/profile`,
1148
1301
  {
@@ -1529,16 +1682,16 @@ function requireHandle(cred) {
1529
1682
  if (cred.handle) return cred.handle;
1530
1683
  console.error(
1531
1684
  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."
1685
+ 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
1686
  )
1534
1687
  );
1535
1688
  process.exitCode = 1;
1536
1689
  return null;
1537
1690
  }
1538
- function assertHandleUnchanged(existing, handle) {
1691
+ function assertHandleUnchanged(existing, cred, handle) {
1539
1692
  if (existing && existing.handle.toLowerCase() !== handle.toLowerCase()) {
1540
1693
  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.`
1694
+ `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
1695
  );
1543
1696
  }
1544
1697
  }
@@ -1600,11 +1753,13 @@ async function publish(io) {
1600
1753
  return readFileSync(p, "utf8");
1601
1754
  }
1602
1755
  });
1603
- const existing = await fetchProfileJson(handle);
1604
- assertHandleUnchanged(existing, handle);
1756
+ const own = await fetchOwnProfile(cred);
1757
+ const existing = own?.profile ?? null;
1758
+ assertHandleUnchanged(existing, cred, handle);
1605
1759
  const defaults = buildDefaults(existing, detected);
1606
- const carried = unknownEntries(existing);
1607
- const extras = existing?.extras ?? [];
1760
+ let carried = unknownEntries(existing);
1761
+ let extras = existing?.extras ?? [];
1762
+ let ifMatch = own?.etag;
1608
1763
  const color = colorEnabled();
1609
1764
  const site = displayUrl(BASE);
1610
1765
  const showCard = (entries) => {
@@ -1632,6 +1787,15 @@ async function publish(io) {
1632
1787
  };
1633
1788
  let values = defaults;
1634
1789
  const assemble = () => [...entriesFromMap(values), ...carried];
1790
+ const edits = /* @__PURE__ */ new Map();
1791
+ const prompt = async (prompter) => {
1792
+ const before = values;
1793
+ values = await promptEntries(values, prompter);
1794
+ for (const key of CURATED_KEYS) {
1795
+ const after = values.get(key);
1796
+ if (after !== before.get(key)) edits.set(key, after);
1797
+ }
1798
+ };
1635
1799
  if (!io.interactive || !io.prompter || io.yes) {
1636
1800
  const over = [...values].find(([, v]) => v.length > MAX_VALUE);
1637
1801
  if (over) {
@@ -1645,11 +1809,14 @@ async function publish(io) {
1645
1809
  }
1646
1810
  const entries = assemble();
1647
1811
  showCard(entries);
1648
- printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1812
+ printPublished(
1813
+ await publishProfile(newProfile(handle, entries, extras), cred, { ifMatch }),
1814
+ color
1815
+ );
1649
1816
  return;
1650
1817
  }
1651
1818
  try {
1652
- if (!existing) values = await promptEntries(values, io.prompter);
1819
+ if (!existing) await prompt(io.prompter);
1653
1820
  for (; ; ) {
1654
1821
  const entries = assemble();
1655
1822
  showCard(entries);
@@ -1661,9 +1828,37 @@ async function publish(io) {
1661
1828
  );
1662
1829
  if (ans === "y") {
1663
1830
  try {
1664
- printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1831
+ printPublished(
1832
+ await publishProfile(newProfile(handle, entries, extras), cred, { ifMatch }),
1833
+ color
1834
+ );
1665
1835
  return;
1666
1836
  } catch (e) {
1837
+ if (e instanceof ProfileChanged) {
1838
+ console.error(
1839
+ message(
1840
+ "Your profile changed since this command read it. Reloaded the current version; your answers are kept."
1841
+ )
1842
+ );
1843
+ const liveCred = cred.source === "env" ? cred : await loadCredential() ?? cred;
1844
+ const fresh = await fetchOwnProfile(liveCred);
1845
+ if (!fresh) {
1846
+ throw new PublishRefusal(
1847
+ "Your profile was deleted since this command read it. Nothing was published. Run `ymmv` again to recreate it."
1848
+ );
1849
+ }
1850
+ assertHandleUnchanged(fresh.profile, cred, handle);
1851
+ const rebased = buildDefaults(fresh.profile, detected);
1852
+ for (const [key, value] of edits) {
1853
+ if (value === void 0) rebased.delete(key);
1854
+ else rebased.set(key, value);
1855
+ }
1856
+ values = rebased;
1857
+ carried = unknownEntries(fresh.profile);
1858
+ extras = fresh.profile.extras;
1859
+ ifMatch = fresh.etag;
1860
+ continue;
1861
+ }
1667
1862
  if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
1668
1863
  const ambiguous = e instanceof NetworkError || isTimeoutError(e);
1669
1864
  console.error(
@@ -1679,7 +1874,7 @@ ${ambiguous ? "The publish may not have completed. Your answers are kept." : "No
1679
1874
  console.log(message("Aborted. Nothing published."));
1680
1875
  return;
1681
1876
  }
1682
- values = await promptEntries(values, io.prompter);
1877
+ await prompt(io.prompter);
1683
1878
  }
1684
1879
  } catch (e) {
1685
1880
  if (e instanceof PromptAborted) {
@@ -1698,7 +1893,26 @@ async function view(handle) {
1698
1893
  console.log(notFound(handle, c, BASE));
1699
1894
  return;
1700
1895
  }
1701
- const cred = await loadToken();
1896
+ const plainCard = (note, wrap = true) => {
1897
+ console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
1898
+ if (note) {
1899
+ const codes = palette(c);
1900
+ console.error(message(`${codes.faint}${wrap ? `(${note})` : note}${codes.reset}`));
1901
+ }
1902
+ };
1903
+ let cred = await loadCredential();
1904
+ if (cred?.source === "env") {
1905
+ try {
1906
+ cred = await verifyEnvCredential(cred);
1907
+ } catch (e) {
1908
+ plainCard(`No diff: ${displayError(e)}`, false);
1909
+ return;
1910
+ }
1911
+ if (cred.handle === null) {
1912
+ plainCard("no diff: the account behind YMMV_TOKEN has no handle bound");
1913
+ return;
1914
+ }
1915
+ }
1702
1916
  if (cred?.handle) {
1703
1917
  let mineFailed = false;
1704
1918
  const mine = await fetchProfileJson(cred.handle).catch(() => {
@@ -1712,24 +1926,24 @@ async function view(handle) {
1712
1926
  return;
1713
1927
  }
1714
1928
  if (!mine) {
1715
- console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
1716
1929
  if (mineFailed) {
1717
- const codes = palette(c);
1718
- console.error(message(`${codes.faint}(couldn't load your profile to diff)${codes.reset}`));
1930
+ plainCard("couldn't load your profile to diff");
1719
1931
  } else {
1932
+ plainCard();
1720
1933
  console.log(nudge(c));
1721
1934
  }
1722
1935
  return;
1723
1936
  }
1724
1937
  }
1725
- console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
1938
+ plainCard();
1726
1939
  }
1727
1940
  async function runSet(target) {
1728
1941
  const cred = await ensureLogin();
1729
1942
  const handle = requireHandle(cred);
1730
1943
  if (!handle) return;
1731
- const existing = await fetchProfileJson(handle);
1732
- assertHandleUnchanged(existing, handle);
1944
+ const own = await fetchOwnProfile(cred);
1945
+ const existing = own?.profile ?? null;
1946
+ assertHandleUnchanged(existing, cred, handle);
1733
1947
  const { entries, extras } = applySet(existing, target);
1734
1948
  if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
1735
1949
  console.error(
@@ -1740,36 +1954,47 @@ async function runSet(target) {
1740
1954
  process.exitCode = 1;
1741
1955
  return;
1742
1956
  }
1743
- const res = await publishProfile(newProfile(handle, entries, extras), cred);
1957
+ const res = await publishProfile(newProfile(handle, entries, extras), cred, {
1958
+ ifMatch: own?.etag
1959
+ });
1744
1960
  const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
1745
1961
  console.log(message(`${line}${pagePointer(res.handle)}`));
1746
1962
  }
1963
+ var PASTE_SAFE_LABEL = /^[\p{L}\p{N} _.+/:-]+$/u;
1964
+ function extraHint(existing, label) {
1965
+ const eq = label.indexOf("=");
1966
+ const head = eq > 0 ? label.slice(0, eq).trim() : "";
1967
+ if (!head || !applyUnset(existing, { kind: "extra", label: head }).removed) return "";
1968
+ const shown = PASTE_SAFE_LABEL.test(head) ? head : "Label";
1969
+ return `
1970
+ (unset takes just the label: ymmv unset --extra "${shown}")`;
1971
+ }
1747
1972
  async function runUnset(target) {
1748
1973
  const cred = await ensureLogin();
1749
1974
  const handle = requireHandle(cred);
1750
1975
  if (!handle) return;
1751
- const existing = await fetchProfileJson(handle);
1752
- assertHandleUnchanged(existing, handle);
1976
+ const own = await fetchOwnProfile(cred);
1977
+ const existing = own?.profile ?? null;
1978
+ assertHandleUnchanged(existing, cred, handle);
1753
1979
  if (!existing) {
1754
1980
  console.log(message("No profile yet. Run `ymmv` to publish one."));
1755
1981
  return;
1756
1982
  }
1757
1983
  const { entries, extras, removed } = applyUnset(existing, target);
1758
1984
  if (!removed) {
1759
- console.log(
1760
- message(
1761
- target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${target.label}".`
1762
- )
1763
- );
1985
+ const line2 = target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${sanitizeValue(target.label)}".${extraHint(existing, target.label)}`;
1986
+ console.log(message(line2));
1764
1987
  return;
1765
1988
  }
1766
- const res = await publishProfile(newProfile(handle, entries, extras), cred);
1989
+ const res = await publishProfile(newProfile(handle, entries, extras), cred, {
1990
+ ifMatch: own?.etag
1991
+ });
1767
1992
  const line = target.kind === "curated" ? `Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").` : `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`;
1768
1993
  console.log(message(`${line}${pagePointer(res.handle)}`));
1769
1994
  }
1770
1995
  async function runDelete(io) {
1771
1996
  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";
1997
+ const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : cred.source === "env" ? "the profile bound to YMMV_TOKEN" : "your profile";
1773
1998
  if (!io.yes) {
1774
1999
  if (!io.interactive || !io.prompter) {
1775
2000
  console.error(
@@ -1863,12 +2088,7 @@ function parseUnset(rest) {
1863
2088
  if (head === "--extra" || head === "-e") {
1864
2089
  const label = rest.slice(1).join(" ").trim();
1865
2090
  if (!label) return { kind: "error", message: `usage: ${UNSET_EXTRA}` };
1866
- if (label.includes("=")) {
1867
- return {
1868
- kind: "error",
1869
- message: 'unset takes just the label: ymmv unset --extra "Keyboard"'
1870
- };
1871
- }
2091
+ if (label.length > MAX_LABEL) return labelCapError(label);
1872
2092
  return { kind: "unset", target: { kind: "extra", label } };
1873
2093
  }
1874
2094
  if (!head) return { kind: "error", message: UNSET_USAGE };
@@ -2233,7 +2453,9 @@ ${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser,
2233
2453
  font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
2234
2454
  async function logout() {
2235
2455
  const stored = await loadToken();
2236
- if (!stored) {
2456
+ const leftover = stored ? null : await peekCredential();
2457
+ const token = stored?.token ?? (retirable(leftover) ? leftover.token : null);
2458
+ if (token === null) {
2237
2459
  const otherBase = await peekBase();
2238
2460
  console.log(
2239
2461
  message(
@@ -2244,7 +2466,7 @@ async function logout() {
2244
2466
  }
2245
2467
  let revoked;
2246
2468
  try {
2247
- revoked = await revokeYmmvToken(stored.token);
2469
+ revoked = await revokeYmmvToken(token);
2248
2470
  } catch (e) {
2249
2471
  console.error(
2250
2472
  message(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {