ymmv-cli 0.11.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 +5 -3
  2. package/dist/cli.js +180 -42
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -69,9 +69,11 @@ 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, and `YMMV_TOKEN` needs
74
- the server's account lookup.
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.
75
77
  - `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
76
78
  the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
77
79
  `ymmv login` / `ymmv logout` keep acting on the stored login. The CLI asks the server which
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) {
@@ -695,19 +721,23 @@ function parseIdentity(data) {
695
721
  }
696
722
  return { github_id: id, handle };
697
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
+ }
698
727
  var MintRejected = class extends Error {
699
728
  constructor(msg) {
700
729
  super(msg);
701
730
  this.name = "MintRejected";
702
731
  }
703
732
  };
704
- async function mintYmmvToken(accessToken) {
733
+ async function mintYmmvToken(accessToken, revoke) {
705
734
  const res = await safeFetch(
706
735
  `${BASE}/api/v1/auth/token`,
707
736
  {
708
737
  method: "POST",
709
738
  headers: { "content-type": "application/json" },
710
- 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 }),
711
741
  // Never follow a redirect: a 30x must fail (the existing `!res.ok` guard rejects the resulting
712
742
  // opaqueredirect), not re-POST the GitHub access_token to the redirect target or read a
713
743
  // redirected 200 as a successful mint. Mirrors publish/delete in api.ts.
@@ -742,23 +772,29 @@ async function mintYmmvToken(accessToken) {
742
772
  throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
743
773
  }
744
774
  const data = await bodyJson(res);
745
- 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.";
746
776
  if (!data) throw new Error(unexpected);
747
777
  const token = typeof data.token === "string" && data.token.length > 0 ? data.token : null;
748
778
  const identity = parseIdentity(data);
749
- if (token === null || identity === null) {
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) {
750
782
  let revokeFailed = false;
751
783
  if (token !== null) {
752
784
  await revokeYmmvToken(token, AbortSignal.timeout(REVOKE_CAP_MS)).catch(() => {
753
785
  revokeFailed = true;
754
786
  });
755
787
  }
788
+ const refusal = behind ?? unexpected;
756
789
  throw new MintRejected(
757
- 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
758
791
  );
759
792
  }
760
793
  return { token, ...identity };
761
794
  }
795
+ function missingRouteError(missing) {
796
+ return new Error(`${BASE} has no ${missing}. ${serverBehindHint()}`);
797
+ }
762
798
  async function fetchWhoami(token) {
763
799
  const res = await safeFetch(
764
800
  `${BASE}/api/v1/auth/whoami`,
@@ -773,9 +809,7 @@ async function fetchWhoami(token) {
773
809
  if (!res.ok) {
774
810
  if (res.status === 401) throw new Error(ENV_TOKEN_REJECTED_MINT_AGAIN);
775
811
  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
- );
812
+ throw missingRouteError("identity lookup for YMMV_TOKEN");
779
813
  }
780
814
  throw new Error(
781
815
  withRetryHint(
@@ -996,6 +1030,9 @@ async function pollForToken(dc, deps = {}) {
996
1030
  }
997
1031
  throw new Error("Device code expired. Run `ymmv login` again.");
998
1032
  }
1033
+ function retirable(cred) {
1034
+ return cred != null && cred.base === BASE && cred.token.trim() !== "";
1035
+ }
999
1036
  async function login(deps = {}) {
1000
1037
  if (!process.stdin.isTTY) {
1001
1038
  throw new Error(
@@ -1028,18 +1065,23 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
1028
1065
  )
1029
1066
  );
1030
1067
  const accessToken = await pollForToken(dc, deps);
1031
- const minted = await mintYmmvToken(accessToken);
1032
- 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();
1033
1072
  try {
1034
1073
  await saveToken(minted);
1035
1074
  } catch (e) {
1036
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
+ );
1037
1079
  });
1038
1080
  throw e;
1039
1081
  }
1040
- if (replaced && replaced.base === BASE && replaced.token !== minted.token) {
1082
+ if (retirable(after) && after.token !== revoke && after.token !== minted.token) {
1041
1083
  try {
1042
- await revokeYmmvToken(replaced.token);
1084
+ await revokeYmmvToken(after.token);
1043
1085
  } catch {
1044
1086
  console.error(message(`${c.faint}(couldn't revoke the previous session's token)${c.reset}`));
1045
1087
  }
@@ -1058,6 +1100,12 @@ var PublishRefusal = class extends Error {
1058
1100
  this.name = "PublishRefusal";
1059
1101
  }
1060
1102
  };
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
+ };
1061
1109
  var NOT_PERSISTED = "Login did not persist a token. Run `ymmv login`.";
1062
1110
  function assertVerified(cred) {
1063
1111
  if (cred.source === "env" && cred.github_id === null) {
@@ -1099,12 +1147,16 @@ async function ensureLogin() {
1099
1147
  if (!fresh) throw new Error(NOT_PERSISTED);
1100
1148
  return fresh;
1101
1149
  }
1102
- async function publishProfile(profile, expected) {
1150
+ async function publishProfile(profile, expected, opts = {}) {
1103
1151
  const send = (c) => safeFetch(
1104
1152
  `${BASE}/api/v1/profile`,
1105
1153
  {
1106
1154
  method: "POST",
1107
- 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
+ },
1108
1160
  // Send the login-bound handle, never a caller-guessed one — the official client never claims
1109
1161
  // a handle it doesn't own.
1110
1162
  body: JSON.stringify({ ...profile, handle: c.handle ?? profile.handle }),
@@ -1182,6 +1234,7 @@ async function publishProfile(profile, expected) {
1182
1234
  );
1183
1235
  }
1184
1236
  }
1237
+ if (res.status === 412) throw new ProfileChanged();
1185
1238
  if (res.status === 429) throw new Error(await rateLimitMessage(res));
1186
1239
  if (!res.ok) {
1187
1240
  const raw = await wireBody(res);
@@ -1207,6 +1260,40 @@ async function fetchProfileJson(handle) {
1207
1260
  }
1208
1261
  return parseProfile(await res.json());
1209
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
+ }
1210
1297
  async function deleteProfile(cred) {
1211
1298
  assertVerified(cred);
1212
1299
  const res = await safeFetch(
@@ -1666,11 +1753,13 @@ async function publish(io) {
1666
1753
  return readFileSync(p, "utf8");
1667
1754
  }
1668
1755
  });
1669
- const existing = await fetchProfileJson(handle);
1756
+ const own = await fetchOwnProfile(cred);
1757
+ const existing = own?.profile ?? null;
1670
1758
  assertHandleUnchanged(existing, cred, handle);
1671
1759
  const defaults = buildDefaults(existing, detected);
1672
- const carried = unknownEntries(existing);
1673
- const extras = existing?.extras ?? [];
1760
+ let carried = unknownEntries(existing);
1761
+ let extras = existing?.extras ?? [];
1762
+ let ifMatch = own?.etag;
1674
1763
  const color = colorEnabled();
1675
1764
  const site = displayUrl(BASE);
1676
1765
  const showCard = (entries) => {
@@ -1698,6 +1787,15 @@ async function publish(io) {
1698
1787
  };
1699
1788
  let values = defaults;
1700
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
+ };
1701
1799
  if (!io.interactive || !io.prompter || io.yes) {
1702
1800
  const over = [...values].find(([, v]) => v.length > MAX_VALUE);
1703
1801
  if (over) {
@@ -1711,11 +1809,14 @@ async function publish(io) {
1711
1809
  }
1712
1810
  const entries = assemble();
1713
1811
  showCard(entries);
1714
- printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1812
+ printPublished(
1813
+ await publishProfile(newProfile(handle, entries, extras), cred, { ifMatch }),
1814
+ color
1815
+ );
1715
1816
  return;
1716
1817
  }
1717
1818
  try {
1718
- if (!existing) values = await promptEntries(values, io.prompter);
1819
+ if (!existing) await prompt(io.prompter);
1719
1820
  for (; ; ) {
1720
1821
  const entries = assemble();
1721
1822
  showCard(entries);
@@ -1727,9 +1828,37 @@ async function publish(io) {
1727
1828
  );
1728
1829
  if (ans === "y") {
1729
1830
  try {
1730
- printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1831
+ printPublished(
1832
+ await publishProfile(newProfile(handle, entries, extras), cred, { ifMatch }),
1833
+ color
1834
+ );
1731
1835
  return;
1732
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
+ }
1733
1862
  if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
1734
1863
  const ambiguous = e instanceof NetworkError || isTimeoutError(e);
1735
1864
  console.error(
@@ -1745,7 +1874,7 @@ ${ambiguous ? "The publish may not have completed. Your answers are kept." : "No
1745
1874
  console.log(message("Aborted. Nothing published."));
1746
1875
  return;
1747
1876
  }
1748
- values = await promptEntries(values, io.prompter);
1877
+ await prompt(io.prompter);
1749
1878
  }
1750
1879
  } catch (e) {
1751
1880
  if (e instanceof PromptAborted) {
@@ -1812,7 +1941,8 @@ async function runSet(target) {
1812
1941
  const cred = await ensureLogin();
1813
1942
  const handle = requireHandle(cred);
1814
1943
  if (!handle) return;
1815
- const existing = await fetchProfileJson(handle);
1944
+ const own = await fetchOwnProfile(cred);
1945
+ const existing = own?.profile ?? null;
1816
1946
  assertHandleUnchanged(existing, cred, handle);
1817
1947
  const { entries, extras } = applySet(existing, target);
1818
1948
  if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
@@ -1824,15 +1954,27 @@ async function runSet(target) {
1824
1954
  process.exitCode = 1;
1825
1955
  return;
1826
1956
  }
1827
- const res = await publishProfile(newProfile(handle, entries, extras), cred);
1957
+ const res = await publishProfile(newProfile(handle, entries, extras), cred, {
1958
+ ifMatch: own?.etag
1959
+ });
1828
1960
  const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
1829
1961
  console.log(message(`${line}${pagePointer(res.handle)}`));
1830
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
+ }
1831
1972
  async function runUnset(target) {
1832
1973
  const cred = await ensureLogin();
1833
1974
  const handle = requireHandle(cred);
1834
1975
  if (!handle) return;
1835
- const existing = await fetchProfileJson(handle);
1976
+ const own = await fetchOwnProfile(cred);
1977
+ const existing = own?.profile ?? null;
1836
1978
  assertHandleUnchanged(existing, cred, handle);
1837
1979
  if (!existing) {
1838
1980
  console.log(message("No profile yet. Run `ymmv` to publish one."));
@@ -1840,14 +1982,13 @@ async function runUnset(target) {
1840
1982
  }
1841
1983
  const { entries, extras, removed } = applyUnset(existing, target);
1842
1984
  if (!removed) {
1843
- console.log(
1844
- message(
1845
- target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${target.label}".`
1846
- )
1847
- );
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));
1848
1987
  return;
1849
1988
  }
1850
- const res = await publishProfile(newProfile(handle, entries, extras), cred);
1989
+ const res = await publishProfile(newProfile(handle, entries, extras), cred, {
1990
+ ifMatch: own?.etag
1991
+ });
1851
1992
  const line = target.kind === "curated" ? `Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").` : `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`;
1852
1993
  console.log(message(`${line}${pagePointer(res.handle)}`));
1853
1994
  }
@@ -1947,12 +2088,7 @@ function parseUnset(rest) {
1947
2088
  if (head === "--extra" || head === "-e") {
1948
2089
  const label = rest.slice(1).join(" ").trim();
1949
2090
  if (!label) return { kind: "error", message: `usage: ${UNSET_EXTRA}` };
1950
- if (label.includes("=")) {
1951
- return {
1952
- kind: "error",
1953
- message: 'unset takes just the label: ymmv unset --extra "Keyboard"'
1954
- };
1955
- }
2091
+ if (label.length > MAX_LABEL) return labelCapError(label);
1956
2092
  return { kind: "unset", target: { kind: "extra", label } };
1957
2093
  }
1958
2094
  if (!head) return { kind: "error", message: UNSET_USAGE };
@@ -2317,7 +2453,9 @@ ${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser,
2317
2453
  font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
2318
2454
  async function logout() {
2319
2455
  const stored = await loadToken();
2320
- if (!stored) {
2456
+ const leftover = stored ? null : await peekCredential();
2457
+ const token = stored?.token ?? (retirable(leftover) ? leftover.token : null);
2458
+ if (token === null) {
2321
2459
  const otherBase = await peekBase();
2322
2460
  console.log(
2323
2461
  message(
@@ -2328,7 +2466,7 @@ async function logout() {
2328
2466
  }
2329
2467
  let revoked;
2330
2468
  try {
2331
- revoked = await revokeYmmvToken(stored.token);
2469
+ revoked = await revokeYmmvToken(token);
2332
2470
  } catch (e) {
2333
2471
  console.error(
2334
2472
  message(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.11.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": {