ymmv-cli 0.11.0 → 0.11.2

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 +7 -5
  2. package/dist/cli.js +279 -79
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -58,8 +58,8 @@ via npm Trusted Publishing, with provenance.
58
58
  the commands to run by hand)
59
59
  - `ymmv version` prints the CLI version (and notes a newer release when one is known)
60
60
 
61
- Values are capped at 256 characters and extra labels at 64; a profile holds up
62
- to 32 extras.
61
+ Values are capped at 256 characters and extra labels at 64, and each needs at
62
+ least one visible character; a profile holds up to 32 extras.
63
63
 
64
64
  Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full contract
65
65
  (shape, statuses, caching, CORS):
@@ -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) {
@@ -365,6 +393,12 @@ function isValidHandle(handle) {
365
393
  return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
366
394
  }
367
395
 
396
+ // ../shared/dist/visible.js
397
+ var VISIBLE_RE = /[^\s\p{Default_Ignorable_Code_Point}\p{Cc}]/u;
398
+ function hasVisibleContent(s) {
399
+ return VISIBLE_RE.test(s);
400
+ }
401
+
368
402
  // src/render.ts
369
403
  var ESC = String.fromCharCode(27);
370
404
  var CSI = `${ESC}[`;
@@ -397,6 +431,9 @@ var BIDI_RE = new RegExp("\\p{Bidi_Control}", "gu");
397
431
  function sanitizeValue(value) {
398
432
  return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
399
433
  }
434
+ function showsVisibleText(value) {
435
+ return hasVisibleContent(sanitizeValue(value));
436
+ }
400
437
  function useColor(env, isTTY) {
401
438
  if (env.NO_COLOR !== void 0) return false;
402
439
  if (env.FORCE_COLOR !== void 0) {
@@ -410,12 +447,10 @@ function colorEnabled() {
410
447
  }
411
448
  var OSC = `${ESC}]`;
412
449
  var ST = `${ESC}\\`;
413
- function displayUrl(value) {
414
- return value.trim().replace(/^https:\/\/(?=.)/i, "");
415
- }
416
450
  var HTTP_URL_RE = /^https?:\/\/\S+$/i;
417
451
  function isHttpUrl(value) {
418
- return HTTP_URL_RE.test(value.trim());
452
+ const t = value.trim();
453
+ return HTTP_URL_RE.test(t) && URL.canParse(t);
419
454
  }
420
455
  var NO_OSC8_TERMS = /* @__PURE__ */ new Set(["linux", "dumb"]);
421
456
  function link(url, color, term = process.env.TERM, label) {
@@ -571,10 +606,16 @@ function causeText(err) {
571
606
  return sanitizeValue(text);
572
607
  }
573
608
  function wireText(text) {
574
- const clean = sanitizeValue(String(text));
575
- return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
609
+ return capped(sanitizeValue(String(text)));
610
+ }
611
+ var WIRE_TEXT_CAP = 200;
612
+ function head(clean) {
613
+ return [...clean.slice(0, WIRE_TEXT_CAP * 2)].slice(0, WIRE_TEXT_CAP).join("");
614
+ }
615
+ function capped(clean) {
616
+ const kept = head(clean);
617
+ return kept.length < clean.length ? `${kept}\u2026` : clean;
576
618
  }
577
- var INVISIBLE_RE = new RegExp("\\p{Default_Ignorable_Code_Point}", "gu");
578
619
  async function wireBody(res) {
579
620
  try {
580
621
  return await res.text();
@@ -589,8 +630,8 @@ function wireErrorBody(raw) {
589
630
  const out = {};
590
631
  if (typeof body?.error === "string") out.slug = body.error;
591
632
  if (typeof body?.message === "string") {
592
- const clean = wireText(body.message).trim();
593
- if (clean.replace(INVISIBLE_RE, "").trim()) out.message = clean;
633
+ const clean = sanitizeValue(body.message);
634
+ if (hasVisibleContent(head(clean))) out.message = capped(clean).trim();
594
635
  }
595
636
  return out;
596
637
  } catch {
@@ -695,19 +736,23 @@ function parseIdentity(data) {
695
736
  }
696
737
  return { github_id: id, handle };
697
738
  }
739
+ function serverBehindHint() {
740
+ return process.env.YMMV_API ? "Point YMMV_API at an up-to-date server." : "The server is behind this CLI release; try again later.";
741
+ }
698
742
  var MintRejected = class extends Error {
699
743
  constructor(msg) {
700
744
  super(msg);
701
745
  this.name = "MintRejected";
702
746
  }
703
747
  };
704
- async function mintYmmvToken(accessToken) {
748
+ async function mintYmmvToken(accessToken, revoke) {
705
749
  const res = await safeFetch(
706
750
  `${BASE}/api/v1/auth/token`,
707
751
  {
708
752
  method: "POST",
709
753
  headers: { "content-type": "application/json" },
710
- body: JSON.stringify({ access_token: accessToken }),
754
+ // JSON.stringify drops an undefined `revoke`: no key on the wire when there is nothing to retire.
755
+ body: JSON.stringify({ access_token: accessToken, revoke }),
711
756
  // Never follow a redirect: a 30x must fail (the existing `!res.ok` guard rejects the resulting
712
757
  // opaqueredirect), not re-POST the GitHub access_token to the redirect target or read a
713
758
  // redirected 200 as a successful mint. Mirrors publish/delete in api.ts.
@@ -742,23 +787,29 @@ async function mintYmmvToken(accessToken) {
742
787
  throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
743
788
  }
744
789
  const data = await bodyJson(res);
745
- const unexpected = `Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`;
790
+ 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
791
  if (!data) throw new Error(unexpected);
747
792
  const token = typeof data.token === "string" && data.token.length > 0 ? data.token : null;
748
793
  const identity = parseIdentity(data);
749
- if (token === null || identity === null) {
794
+ const revoked = typeof data.revoked === "boolean" ? data.revoked : void 0;
795
+ 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;
796
+ if (token === null || identity === null || behind !== null) {
750
797
  let revokeFailed = false;
751
798
  if (token !== null) {
752
799
  await revokeYmmvToken(token, AbortSignal.timeout(REVOKE_CAP_MS)).catch(() => {
753
800
  revokeFailed = true;
754
801
  });
755
802
  }
803
+ const refusal = behind ?? unexpected;
756
804
  throw new MintRejected(
757
- revokeFailed ? `${unexpected} The login the server minted could not be revoked.` : unexpected
805
+ revokeFailed ? `${refusal} The login the server minted could not be revoked.` : refusal
758
806
  );
759
807
  }
760
808
  return { token, ...identity };
761
809
  }
810
+ function missingRouteError(missing) {
811
+ return new Error(`${BASE} has no ${missing}. ${serverBehindHint()}`);
812
+ }
762
813
  async function fetchWhoami(token) {
763
814
  const res = await safeFetch(
764
815
  `${BASE}/api/v1/auth/whoami`,
@@ -773,9 +824,7 @@ async function fetchWhoami(token) {
773
824
  if (!res.ok) {
774
825
  if (res.status === 401) throw new Error(ENV_TOKEN_REJECTED_MINT_AGAIN);
775
826
  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
- );
827
+ throw missingRouteError("identity lookup for YMMV_TOKEN");
779
828
  }
780
829
  throw new Error(
781
830
  withRetryHint(
@@ -996,6 +1045,9 @@ async function pollForToken(dc, deps = {}) {
996
1045
  }
997
1046
  throw new Error("Device code expired. Run `ymmv login` again.");
998
1047
  }
1048
+ function retirable(cred) {
1049
+ return cred != null && cred.base === BASE && cred.token.trim() !== "";
1050
+ }
999
1051
  async function login(deps = {}) {
1000
1052
  if (!process.stdin.isTTY) {
1001
1053
  throw new Error(
@@ -1028,18 +1080,23 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
1028
1080
  )
1029
1081
  );
1030
1082
  const accessToken = await pollForToken(dc, deps);
1031
- const minted = await mintYmmvToken(accessToken);
1032
- const replaced = await peekCredential();
1083
+ const before = await peekCredential();
1084
+ const revoke = retirable(before) ? before.token : void 0;
1085
+ const minted = await mintYmmvToken(accessToken, revoke);
1086
+ const after = await peekCredential();
1033
1087
  try {
1034
1088
  await saveToken(minted);
1035
1089
  } catch (e) {
1036
1090
  await revokeYmmvToken(minted.token).catch(() => {
1091
+ console.error(
1092
+ message(`${c.faint}(the login the server minted could not be revoked)${c.reset}`)
1093
+ );
1037
1094
  });
1038
1095
  throw e;
1039
1096
  }
1040
- if (replaced && replaced.base === BASE && replaced.token !== minted.token) {
1097
+ if (retirable(after) && after.token !== revoke && after.token !== minted.token) {
1041
1098
  try {
1042
- await revokeYmmvToken(replaced.token);
1099
+ await revokeYmmvToken(after.token);
1043
1100
  } catch {
1044
1101
  console.error(message(`${c.faint}(couldn't revoke the previous session's token)${c.reset}`));
1045
1102
  }
@@ -1058,6 +1115,12 @@ var PublishRefusal = class extends Error {
1058
1115
  this.name = "PublishRefusal";
1059
1116
  }
1060
1117
  };
1118
+ var ProfileChanged = class extends PublishRefusal {
1119
+ constructor() {
1120
+ super("Your profile changed since this command read it. Re-run the command.");
1121
+ this.name = "ProfileChanged";
1122
+ }
1123
+ };
1061
1124
  var NOT_PERSISTED = "Login did not persist a token. Run `ymmv login`.";
1062
1125
  function assertVerified(cred) {
1063
1126
  if (cred.source === "env" && cred.github_id === null) {
@@ -1099,12 +1162,16 @@ async function ensureLogin() {
1099
1162
  if (!fresh) throw new Error(NOT_PERSISTED);
1100
1163
  return fresh;
1101
1164
  }
1102
- async function publishProfile(profile, expected) {
1165
+ async function publishProfile(profile, expected, opts = {}) {
1103
1166
  const send = (c) => safeFetch(
1104
1167
  `${BASE}/api/v1/profile`,
1105
1168
  {
1106
1169
  method: "POST",
1107
- headers: { "content-type": "application/json", authorization: `Bearer ${c.token}` },
1170
+ headers: {
1171
+ "content-type": "application/json",
1172
+ authorization: `Bearer ${c.token}`,
1173
+ ...opts.ifMatch ? { "if-match": opts.ifMatch } : {}
1174
+ },
1108
1175
  // Send the login-bound handle, never a caller-guessed one — the official client never claims
1109
1176
  // a handle it doesn't own.
1110
1177
  body: JSON.stringify({ ...profile, handle: c.handle ?? profile.handle }),
@@ -1182,6 +1249,7 @@ async function publishProfile(profile, expected) {
1182
1249
  );
1183
1250
  }
1184
1251
  }
1252
+ if (res.status === 412) throw new ProfileChanged();
1185
1253
  if (res.status === 429) throw new Error(await rateLimitMessage(res));
1186
1254
  if (!res.ok) {
1187
1255
  const raw = await wireBody(res);
@@ -1207,6 +1275,40 @@ async function fetchProfileJson(handle) {
1207
1275
  }
1208
1276
  return parseProfile(await res.json());
1209
1277
  }
1278
+ var HEADER_SAFE_TAG = /^[!#-~]{1,128}$/;
1279
+ async function fetchOwnProfile(cred) {
1280
+ assertVerified(cred);
1281
+ const res = await safeFetch(
1282
+ `${BASE}/api/v1/profile`,
1283
+ {
1284
+ headers: { authorization: `Bearer ${cred.token}` },
1285
+ // Never follow a redirect: the bearer must not travel to a redirect target, and a 30x→200
1286
+ // must not read as a profile. Same guard as whoami, mint, logout, publish, and delete.
1287
+ redirect: "manual"
1288
+ },
1289
+ BASE
1290
+ );
1291
+ if (res.status === 401) {
1292
+ throw new Error(
1293
+ cred.source === "env" ? ENV_TOKEN_REJECTED_MINT_AGAIN : "Session expired. Run `ymmv login`, then re-run the command."
1294
+ );
1295
+ }
1296
+ if (res.status === 404) {
1297
+ const { slug } = wireErrorBody(await wireBody(res));
1298
+ if (slug === "not_found") return null;
1299
+ throw missingRouteError("profile lookup");
1300
+ }
1301
+ if (res.status === 429) throw new Error(await rateLimitMessage(res));
1302
+ if (!res.ok) {
1303
+ const raw = await wireBody(res);
1304
+ throw new Error(withRetryHint(`fetch failed: ${res.status} ${wireText(raw)}`, res));
1305
+ }
1306
+ const profile = parseProfile(await res.json());
1307
+ if (!HEADER_SAFE_TAG.test(profile.updated_at)) {
1308
+ throw new Error(`Unexpected response from ${BASE}. Check YMMV_API, or try again shortly.`);
1309
+ }
1310
+ return { profile, etag: `"${profile.updated_at}"` };
1311
+ }
1210
1312
  async function deleteProfile(cred) {
1211
1313
  assertVerified(cred);
1212
1314
  const res = await safeFetch(
@@ -1625,7 +1727,31 @@ function pagePointer(handle) {
1625
1727
  const c = palette(color);
1626
1728
  return ` ${c.faint}\u2192 ${color ? displayUrl(BASE) : BASE}/${handle}${c.reset}`;
1627
1729
  }
1628
- async function promptEntries(defaults, prompter) {
1730
+ function writeRuleRefusal(entries, existing) {
1731
+ const saved = savedKeys(existing);
1732
+ for (const { key, value } of entries) {
1733
+ if (!isCuratedKey(key)) continue;
1734
+ const problem = valueProblem(value);
1735
+ if (problem === void 0) continue;
1736
+ const remedy = problem === "invisible" ? "Set one" : "Set a shorter one";
1737
+ const source = saved.has(key) ? "saved" : "detected";
1738
+ const remove = problem === "invisible" && source === "saved" ? `, or remove it: ymmv unset ${key}` : "";
1739
+ return `The ${source} ${KEY_LABELS[key]} value ${ruleClause(problem, value)}. ${remedy}: ymmv set ${key} <value>${remove}.`;
1740
+ }
1741
+ return void 0;
1742
+ }
1743
+ function valueProblem(value) {
1744
+ if (!showsVisibleText(value)) return "invisible";
1745
+ if (value.length > MAX_VALUE) return "over-cap";
1746
+ return void 0;
1747
+ }
1748
+ function ruleClause(problem, value) {
1749
+ return problem === "invisible" ? "has no visible text" : `is ${value.length} characters; the cap is ${MAX_VALUE}`;
1750
+ }
1751
+ function savedKeys(existing) {
1752
+ return new Set((existing?.entries ?? []).map((e) => e.key));
1753
+ }
1754
+ async function promptEntries(defaults, saved, prompter) {
1629
1755
  const c = palette(colorEnabled());
1630
1756
  console.log(message(`${c.faint}Enter to keep, "-" to clear${c.reset}`));
1631
1757
  const chosen = /* @__PURE__ */ new Map();
@@ -1633,13 +1759,15 @@ async function promptEntries(defaults, prompter) {
1633
1759
  for (; ; ) {
1634
1760
  const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
1635
1761
  const value = answer === "-" ? "" : answer;
1636
- if (value.length > MAX_VALUE) {
1637
- const isDefault = value === (defaults.get(key) ?? "").trim();
1638
- console.log(
1639
- message(
1640
- `${c.faint}${isDefault ? `the saved value is ${value.length} characters; the cap is ${MAX_VALUE}. Type a shorter value or - to clear` : `that value is ${value.length} characters; the cap is ${MAX_VALUE}`}${c.reset}`
1641
- )
1642
- );
1762
+ const rawDefault = defaults.get(key) ?? "";
1763
+ const isDefault = value === sanitizeValue(rawDefault).trim();
1764
+ const emptiedDefault = answer === "" && rawDefault !== "";
1765
+ const problem = emptiedDefault ? "invisible" : value === "" ? void 0 : valueProblem(value);
1766
+ if (problem !== void 0) {
1767
+ const which = `the ${saved.has(key) ? "saved" : "detected"} value`;
1768
+ const clause = ruleClause(problem, value);
1769
+ const note = isDefault ? `${which} ${clause}. Type a ${problem === "invisible" ? "value" : "shorter value"} or - to clear` : `that value ${clause}`;
1770
+ console.log(message(`${c.faint}${note}${c.reset}`));
1643
1771
  continue;
1644
1772
  }
1645
1773
  if (value) chosen.set(key, value);
@@ -1666,11 +1794,13 @@ async function publish(io) {
1666
1794
  return readFileSync(p, "utf8");
1667
1795
  }
1668
1796
  });
1669
- const existing = await fetchProfileJson(handle);
1797
+ const own = await fetchOwnProfile(cred);
1798
+ const existing = own?.profile ?? null;
1670
1799
  assertHandleUnchanged(existing, cred, handle);
1671
1800
  const defaults = buildDefaults(existing, detected);
1672
- const carried = unknownEntries(existing);
1673
- const extras = existing?.extras ?? [];
1801
+ let carried = unknownEntries(existing);
1802
+ let extras = existing?.extras ?? [];
1803
+ let ifMatch = own?.etag;
1674
1804
  const color = colorEnabled();
1675
1805
  const site = displayUrl(BASE);
1676
1806
  const showCard = (entries) => {
@@ -1698,25 +1828,36 @@ async function publish(io) {
1698
1828
  };
1699
1829
  let values = defaults;
1700
1830
  const assemble = () => [...entriesFromMap(values), ...carried];
1831
+ const edits = /* @__PURE__ */ new Map();
1832
+ let saved = savedKeys(existing);
1833
+ const prompt = async (prompter) => {
1834
+ const before = values;
1835
+ values = await promptEntries(values, saved, prompter);
1836
+ for (const key of CURATED_KEYS) {
1837
+ const after = values.get(key);
1838
+ if (after !== before.get(key)) edits.set(key, after);
1839
+ }
1840
+ };
1701
1841
  if (!io.interactive || !io.prompter || io.yes) {
1702
- const over = [...values].find(([, v]) => v.length > MAX_VALUE);
1703
- if (over) {
1704
- console.error(
1705
- message(
1706
- `The detected ${KEY_LABELS[over[0]]} value is ${over[1].length} characters; the cap is ${MAX_VALUE}. Set a shorter one: ymmv set ${over[0]} <value>.`
1707
- )
1708
- );
1842
+ const entries = assemble();
1843
+ const refusal = writeRuleRefusal(entries, existing);
1844
+ if (refusal !== void 0) {
1845
+ console.error(message(refusal));
1709
1846
  process.exitCode = 1;
1710
1847
  return;
1711
1848
  }
1712
- const entries = assemble();
1713
1849
  showCard(entries);
1714
- printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1850
+ printPublished(
1851
+ await publishProfile(newProfile(handle, entries, extras), cred, { ifMatch }),
1852
+ color
1853
+ );
1715
1854
  return;
1716
1855
  }
1717
1856
  try {
1718
- if (!existing) values = await promptEntries(values, io.prompter);
1857
+ if (!existing) await prompt(io.prompter);
1858
+ const failsRule = ([, v]) => valueProblem(v) !== void 0;
1719
1859
  for (; ; ) {
1860
+ if ([...values].some(failsRule)) await prompt(io.prompter);
1720
1861
  const entries = assemble();
1721
1862
  showCard(entries);
1722
1863
  const ans = await io.prompter.choice(
@@ -1727,9 +1868,38 @@ async function publish(io) {
1727
1868
  );
1728
1869
  if (ans === "y") {
1729
1870
  try {
1730
- printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1871
+ printPublished(
1872
+ await publishProfile(newProfile(handle, entries, extras), cred, { ifMatch }),
1873
+ color
1874
+ );
1731
1875
  return;
1732
1876
  } catch (e) {
1877
+ if (e instanceof ProfileChanged) {
1878
+ console.error(
1879
+ message(
1880
+ "Your profile changed since this command read it. Reloaded the current version; your answers are kept."
1881
+ )
1882
+ );
1883
+ const liveCred = cred.source === "env" ? cred : await loadCredential() ?? cred;
1884
+ const fresh = await fetchOwnProfile(liveCred);
1885
+ if (!fresh) {
1886
+ throw new PublishRefusal(
1887
+ "Your profile was deleted since this command read it. Nothing was published. Run `ymmv` again to recreate it."
1888
+ );
1889
+ }
1890
+ assertHandleUnchanged(fresh.profile, cred, handle);
1891
+ const rebased = buildDefaults(fresh.profile, detected);
1892
+ for (const [key, value] of edits) {
1893
+ if (value === void 0) rebased.delete(key);
1894
+ else rebased.set(key, value);
1895
+ }
1896
+ values = rebased;
1897
+ saved = savedKeys(fresh.profile);
1898
+ carried = unknownEntries(fresh.profile);
1899
+ extras = fresh.profile.extras;
1900
+ ifMatch = fresh.etag;
1901
+ continue;
1902
+ }
1733
1903
  if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
1734
1904
  const ambiguous = e instanceof NetworkError || isTimeoutError(e);
1735
1905
  console.error(
@@ -1745,7 +1915,7 @@ ${ambiguous ? "The publish may not have completed. Your answers are kept." : "No
1745
1915
  console.log(message("Aborted. Nothing published."));
1746
1916
  return;
1747
1917
  }
1748
- values = await promptEntries(values, io.prompter);
1918
+ await prompt(io.prompter);
1749
1919
  }
1750
1920
  } catch (e) {
1751
1921
  if (e instanceof PromptAborted) {
@@ -1812,7 +1982,8 @@ async function runSet(target) {
1812
1982
  const cred = await ensureLogin();
1813
1983
  const handle = requireHandle(cred);
1814
1984
  if (!handle) return;
1815
- const existing = await fetchProfileJson(handle);
1985
+ const own = await fetchOwnProfile(cred);
1986
+ const existing = own?.profile ?? null;
1816
1987
  assertHandleUnchanged(existing, cred, handle);
1817
1988
  const { entries, extras } = applySet(existing, target);
1818
1989
  if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
@@ -1824,15 +1995,33 @@ async function runSet(target) {
1824
1995
  process.exitCode = 1;
1825
1996
  return;
1826
1997
  }
1827
- const res = await publishProfile(newProfile(handle, entries, extras), cred);
1828
- const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
1998
+ const refusal = writeRuleRefusal(entries, existing);
1999
+ if (refusal !== void 0) {
2000
+ console.error(message(refusal));
2001
+ process.exitCode = 1;
2002
+ return;
2003
+ }
2004
+ const res = await publishProfile(newProfile(handle, entries, extras), cred, {
2005
+ ifMatch: own?.etag
2006
+ });
2007
+ const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${sanitizeValue(target.value)}.` : `Set extra ${sanitizeValue(target.label)} = ${sanitizeValue(target.value)}.`;
1829
2008
  console.log(message(`${line}${pagePointer(res.handle)}`));
1830
2009
  }
2010
+ var PASTE_SAFE_LABEL = /^[\p{L}\p{N} _.+/:-]+$/u;
2011
+ function extraHint(existing, label) {
2012
+ const eq = label.indexOf("=");
2013
+ const head2 = eq > 0 ? label.slice(0, eq).trim() : "";
2014
+ if (!head2 || !applyUnset(existing, { kind: "extra", label: head2 }).removed) return "";
2015
+ const shown = PASTE_SAFE_LABEL.test(head2) ? head2 : "Label";
2016
+ return `
2017
+ (unset takes just the label: ymmv unset --extra "${shown}")`;
2018
+ }
1831
2019
  async function runUnset(target) {
1832
2020
  const cred = await ensureLogin();
1833
2021
  const handle = requireHandle(cred);
1834
2022
  if (!handle) return;
1835
- const existing = await fetchProfileJson(handle);
2023
+ const own = await fetchOwnProfile(cred);
2024
+ const existing = own?.profile ?? null;
1836
2025
  assertHandleUnchanged(existing, cred, handle);
1837
2026
  if (!existing) {
1838
2027
  console.log(message("No profile yet. Run `ymmv` to publish one."));
@@ -1840,14 +2029,19 @@ async function runUnset(target) {
1840
2029
  }
1841
2030
  const { entries, extras, removed } = applyUnset(existing, target);
1842
2031
  if (!removed) {
1843
- console.log(
1844
- message(
1845
- target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${target.label}".`
1846
- )
1847
- );
2032
+ const line2 = target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${sanitizeValue(target.label)}".${extraHint(existing, target.label)}`;
2033
+ console.log(message(line2));
1848
2034
  return;
1849
2035
  }
1850
- const res = await publishProfile(newProfile(handle, entries, extras), cred);
2036
+ const refusal = writeRuleRefusal(entries, existing);
2037
+ if (refusal !== void 0) {
2038
+ console.error(message(refusal));
2039
+ process.exitCode = 1;
2040
+ return;
2041
+ }
2042
+ const res = await publishProfile(newProfile(handle, entries, extras), cred, {
2043
+ ifMatch: own?.etag
2044
+ });
1851
2045
  const line = target.kind === "curated" ? `Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").` : `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`;
1852
2046
  console.log(message(`${line}${pagePointer(res.handle)}`));
1853
2047
  }
@@ -1893,10 +2087,10 @@ var SET_USAGE = `usage: ymmv set <key> <value> | ${SET_EXTRA}`;
1893
2087
  var EXTRA_USAGE = `usage: ${SET_EXTRA}`;
1894
2088
  var UNSET_USAGE = `usage: ymmv unset <key> | ${UNSET_EXTRA}`;
1895
2089
  var VIEW_USAGE = "usage: ymmv view <handle>";
1896
- function invalidKeyError(head, hint) {
2090
+ function invalidKeyError(head2, hint) {
1897
2091
  return {
1898
2092
  kind: "error",
1899
- message: `"${sanitizeValue(head)}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
2093
+ message: `"${sanitizeValue(head2)}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
1900
2094
  For anything else, use: ${hint}.`
1901
2095
  };
1902
2096
  }
@@ -1920,9 +2114,15 @@ function valueCapError(value) {
1920
2114
  message: `That value is ${value.length} characters; the cap is ${MAX_VALUE}.`
1921
2115
  };
1922
2116
  }
2117
+ function labelInvisibleError() {
2118
+ return { kind: "error", message: "That label has no visible text." };
2119
+ }
2120
+ function valueInvisibleError() {
2121
+ return { kind: "error", message: "That value has no visible text." };
2122
+ }
1923
2123
  function parseSet(rest) {
1924
- const head = rest[0];
1925
- if (head === "--extra" || head === "-e") {
2124
+ const head2 = rest[0];
2125
+ if (head2 === "--extra" || head2 === "-e") {
1926
2126
  const spec = rest.slice(1).join(" ").trim();
1927
2127
  const eq = spec.indexOf("=");
1928
2128
  if (eq <= 0) return { kind: "error", message: EXTRA_USAGE };
@@ -1930,35 +2130,33 @@ function parseSet(rest) {
1930
2130
  const value2 = spec.slice(eq + 1).trim();
1931
2131
  if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
1932
2132
  if (value2 === "-") return { kind: "unset", target: { kind: "extra", label } };
2133
+ if (!showsVisibleText(label)) return labelInvisibleError();
2134
+ if (!showsVisibleText(value2)) return valueInvisibleError();
1933
2135
  if (label.length > MAX_LABEL) return labelCapError(label);
1934
2136
  if (value2.length > MAX_VALUE) return valueCapError(value2);
1935
2137
  return { kind: "set", target: { kind: "extra", label, value: value2 } };
1936
2138
  }
1937
- if (!head) return { kind: "error", message: SET_USAGE };
1938
- if (!isCuratedKey(head)) return invalidKeyError(head, SET_EXTRA);
2139
+ if (!head2) return { kind: "error", message: SET_USAGE };
2140
+ if (!isCuratedKey(head2)) return invalidKeyError(head2, SET_EXTRA);
1939
2141
  const value = rest.slice(1).join(" ").trim();
1940
- if (!value) return { kind: "error", message: `usage: ymmv set ${head} <value>` };
1941
- if (value === "-") return { kind: "unset", target: { kind: "curated", key: head } };
2142
+ if (!value) return { kind: "error", message: `usage: ymmv set ${head2} <value>` };
2143
+ if (value === "-") return { kind: "unset", target: { kind: "curated", key: head2 } };
2144
+ if (!showsVisibleText(value)) return valueInvisibleError();
1942
2145
  if (value.length > MAX_VALUE) return valueCapError(value);
1943
- return { kind: "set", target: { kind: "curated", key: head, value } };
2146
+ return { kind: "set", target: { kind: "curated", key: head2, value } };
1944
2147
  }
1945
2148
  function parseUnset(rest) {
1946
- const head = rest[0];
1947
- if (head === "--extra" || head === "-e") {
2149
+ const head2 = rest[0];
2150
+ if (head2 === "--extra" || head2 === "-e") {
1948
2151
  const label = rest.slice(1).join(" ").trim();
1949
2152
  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
- }
2153
+ if (label.length > MAX_LABEL) return labelCapError(label);
1956
2154
  return { kind: "unset", target: { kind: "extra", label } };
1957
2155
  }
1958
- if (!head) return { kind: "error", message: UNSET_USAGE };
1959
- if (!isCuratedKey(head)) return invalidKeyError(head, UNSET_EXTRA);
1960
- if (rest.length > 1) return { kind: "error", message: `usage: ymmv unset ${head}` };
1961
- return { kind: "unset", target: { kind: "curated", key: head } };
2156
+ if (!head2) return { kind: "error", message: UNSET_USAGE };
2157
+ if (!isCuratedKey(head2)) return invalidKeyError(head2, UNSET_EXTRA);
2158
+ if (rest.length > 1) return { kind: "error", message: `usage: ymmv unset ${head2}` };
2159
+ return { kind: "unset", target: { kind: "curated", key: head2 } };
1962
2160
  }
1963
2161
  function resolveArg(argv) {
1964
2162
  const first = argv[0];
@@ -2317,7 +2515,9 @@ ${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser,
2317
2515
  font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
2318
2516
  async function logout() {
2319
2517
  const stored = await loadToken();
2320
- if (!stored) {
2518
+ const leftover = stored ? null : await peekCredential();
2519
+ const token = stored?.token ?? (retirable(leftover) ? leftover.token : null);
2520
+ if (token === null) {
2321
2521
  const otherBase = await peekBase();
2322
2522
  console.log(
2323
2523
  message(
@@ -2328,7 +2528,7 @@ async function logout() {
2328
2528
  }
2329
2529
  let revoked;
2330
2530
  try {
2331
- revoked = await revokeYmmvToken(stored.token);
2531
+ revoked = await revokeYmmvToken(token);
2332
2532
  } catch (e) {
2333
2533
  console.error(
2334
2534
  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.2",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {