ymmv-cli 0.11.1 → 0.11.3

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 +4 -3
  2. package/dist/cli.js +170 -51
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -46,7 +46,8 @@ via npm Trusted Publishing, with provenance.
46
46
 
47
47
  ## Commands
48
48
 
49
- - `ymmv` detects, confirms, and publishes (re-run any time to update; `ymmv publish` is the same command)
49
+ - `ymmv` detects, confirms, and publishes (re-run any time to update: the preview marks values
50
+ your environment now detects differently and `d` takes them; `ymmv publish` is the same command)
50
51
  - `ymmv <handle>` views a profile, or diffs it against yours when you're logged in
51
52
  - `ymmv set editor Neovim` changes one value
52
53
  - `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own (`-e` works too)
@@ -58,8 +59,8 @@ via npm Trusted Publishing, with provenance.
58
59
  the commands to run by hand)
59
60
  - `ymmv version` prints the CLI version (and notes a newer release when one is known)
60
61
 
61
- Values are capped at 256 characters and extra labels at 64; a profile holds up
62
- to 32 extras.
62
+ Values are capped at 256 characters and extra labels at 64, and each needs at
63
+ least one visible character; a profile holds up to 32 extras.
63
64
 
64
65
  Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full contract
65
66
  (shape, statuses, caching, CORS):
package/dist/cli.js CHANGED
@@ -393,6 +393,12 @@ function isValidHandle(handle) {
393
393
  return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
394
394
  }
395
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
+
396
402
  // src/render.ts
397
403
  var ESC = String.fromCharCode(27);
398
404
  var CSI = `${ESC}[`;
@@ -425,6 +431,12 @@ var BIDI_RE = new RegExp("\\p{Bidi_Control}", "gu");
425
431
  function sanitizeValue(value) {
426
432
  return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
427
433
  }
434
+ function shownValue(value) {
435
+ return sanitizeValue(value).trim();
436
+ }
437
+ function showsVisibleText(value) {
438
+ return hasVisibleContent(sanitizeValue(value));
439
+ }
428
440
  function useColor(env, isTTY) {
429
441
  if (env.NO_COLOR !== void 0) return false;
430
442
  if (env.FORCE_COLOR !== void 0) {
@@ -473,7 +485,13 @@ function renderProfile(profile, opts) {
473
485
  const rows = CURATED_KEYS.flatMap((key) => {
474
486
  const value = byKey.get(key);
475
487
  if (value === void 0 && !preview) return [];
476
- return [{ label: KEY_LABELS[key], value: value === void 0 ? null : sanitizeValue(value) }];
488
+ return [
489
+ {
490
+ label: KEY_LABELS[key],
491
+ value: value === void 0 ? null : sanitizeValue(value),
492
+ note: preview && value !== void 0 ? opts.disagreements?.get(key) : void 0
493
+ }
494
+ ];
477
495
  });
478
496
  const extras = (profile.extras ?? []).map((x) => ({
479
497
  label: sanitizeValue(x.label),
@@ -492,9 +510,12 @@ function renderProfile(profile, opts) {
492
510
  if (rows.length) {
493
511
  lines.push("");
494
512
  for (const r of rows) {
495
- lines.push(
496
- r.value === null ? ` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}` : ` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}`
497
- );
513
+ if (r.value === null) {
514
+ lines.push(` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}`);
515
+ continue;
516
+ }
517
+ const note = r.note ? ` ${c.faint}(detected: ${sanitizeValue(r.note)})${c.reset}` : "";
518
+ lines.push(` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}${note}`);
498
519
  }
499
520
  }
500
521
  if (extras.length) {
@@ -597,10 +618,16 @@ function causeText(err) {
597
618
  return sanitizeValue(text);
598
619
  }
599
620
  function wireText(text) {
600
- const clean = sanitizeValue(String(text));
601
- return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
621
+ return capped(sanitizeValue(String(text)));
622
+ }
623
+ var WIRE_TEXT_CAP = 200;
624
+ function head(clean) {
625
+ return [...clean.slice(0, WIRE_TEXT_CAP * 2)].slice(0, WIRE_TEXT_CAP).join("");
626
+ }
627
+ function capped(clean) {
628
+ const kept = head(clean);
629
+ return kept.length < clean.length ? `${kept}\u2026` : clean;
602
630
  }
603
- var INVISIBLE_RE = new RegExp("\\p{Default_Ignorable_Code_Point}", "gu");
604
631
  async function wireBody(res) {
605
632
  try {
606
633
  return await res.text();
@@ -615,8 +642,8 @@ function wireErrorBody(raw) {
615
642
  const out = {};
616
643
  if (typeof body?.error === "string") out.slug = body.error;
617
644
  if (typeof body?.message === "string") {
618
- const clean = wireText(body.message).trim();
619
- if (clean.replace(INVISIBLE_RE, "").trim()) out.message = clean;
645
+ const clean = sanitizeValue(body.message);
646
+ if (hasVisibleContent(head(clean))) out.message = capped(clean).trim();
620
647
  }
621
648
  return out;
622
649
  } catch {
@@ -1539,6 +1566,17 @@ function buildDefaults(existing, detected) {
1539
1566
  }
1540
1567
  return out;
1541
1568
  }
1569
+ function detectionDisagreements(values, detected, decided = /* @__PURE__ */ new Set()) {
1570
+ const out = /* @__PURE__ */ new Map();
1571
+ for (const key of CURATED_KEYS) {
1572
+ const current = values.get(key);
1573
+ const fresh = detected.get(key);
1574
+ if (current === void 0 || fresh === void 0 || decided.has(key)) continue;
1575
+ const take = shownValue(fresh);
1576
+ if (take && canonical(key, shownValue(current)) !== canonical(key, take)) out.set(key, take);
1577
+ }
1578
+ return out;
1579
+ }
1542
1580
  function entriesFromMap(map) {
1543
1581
  return CURATED_KEYS.flatMap((key) => {
1544
1582
  const value = map.get(key);
@@ -1712,7 +1750,31 @@ function pagePointer(handle) {
1712
1750
  const c = palette(color);
1713
1751
  return ` ${c.faint}\u2192 ${color ? displayUrl(BASE) : BASE}/${handle}${c.reset}`;
1714
1752
  }
1715
- async function promptEntries(defaults, prompter) {
1753
+ function writeRuleRefusal(entries, existing) {
1754
+ const saved = savedKeys(existing);
1755
+ for (const { key, value } of entries) {
1756
+ if (!isCuratedKey(key)) continue;
1757
+ const problem = valueProblem(value);
1758
+ if (problem === void 0) continue;
1759
+ const remedy = problem === "invisible" ? "Set one" : "Set a shorter one";
1760
+ const source = saved.has(key) ? "saved" : "detected";
1761
+ const remove = problem === "invisible" && source === "saved" ? `, or remove it: ymmv unset ${key}` : "";
1762
+ return `The ${source} ${KEY_LABELS[key]} value ${ruleClause(problem, value)}. ${remedy}: ymmv set ${key} <value>${remove}.`;
1763
+ }
1764
+ return void 0;
1765
+ }
1766
+ function valueProblem(value) {
1767
+ if (!showsVisibleText(value)) return "invisible";
1768
+ if (value.length > MAX_VALUE) return "over-cap";
1769
+ return void 0;
1770
+ }
1771
+ function ruleClause(problem, value) {
1772
+ return problem === "invisible" ? "has no visible text" : `is ${value.length} characters; the cap is ${MAX_VALUE}`;
1773
+ }
1774
+ function savedKeys(existing) {
1775
+ return new Set((existing?.entries ?? []).map((e) => e.key));
1776
+ }
1777
+ async function promptEntries(defaults, saved, prompter) {
1716
1778
  const c = palette(colorEnabled());
1717
1779
  console.log(message(`${c.faint}Enter to keep, "-" to clear${c.reset}`));
1718
1780
  const chosen = /* @__PURE__ */ new Map();
@@ -1720,13 +1782,15 @@ async function promptEntries(defaults, prompter) {
1720
1782
  for (; ; ) {
1721
1783
  const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
1722
1784
  const value = answer === "-" ? "" : answer;
1723
- if (value.length > MAX_VALUE) {
1724
- const isDefault = value === (defaults.get(key) ?? "").trim();
1725
- console.log(
1726
- message(
1727
- `${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}`
1728
- )
1729
- );
1785
+ const rawDefault = defaults.get(key) ?? "";
1786
+ const isDefault = value === shownValue(rawDefault);
1787
+ const emptiedDefault = answer === "" && rawDefault !== "";
1788
+ const problem = emptiedDefault ? "invisible" : value === "" ? void 0 : valueProblem(value);
1789
+ if (problem !== void 0) {
1790
+ const which = `the ${saved.has(key) ? "saved" : "detected"} value`;
1791
+ const clause = ruleClause(problem, value);
1792
+ const note = isDefault ? `${which} ${clause}. Type a ${problem === "invisible" ? "value" : "shorter value"} or - to clear` : `that value ${clause}`;
1793
+ console.log(message(`${c.faint}${note}${c.reset}`));
1730
1794
  continue;
1731
1795
  }
1732
1796
  if (value) chosen.set(key, value);
@@ -1762,9 +1826,16 @@ async function publish(io) {
1762
1826
  let ifMatch = own?.etag;
1763
1827
  const color = colorEnabled();
1764
1828
  const site = displayUrl(BASE);
1765
- const showCard = (entries) => {
1829
+ let cardShown = false;
1830
+ const showCard = (entries, disagreements) => {
1831
+ cardShown = true;
1766
1832
  console.log(
1767
- renderProfile(newProfile(handle, entries, extras), { color, site, mode: "preview" })
1833
+ renderProfile(newProfile(handle, entries, extras), {
1834
+ color,
1835
+ site,
1836
+ mode: "preview",
1837
+ disagreements
1838
+ })
1768
1839
  );
1769
1840
  const notes = [];
1770
1841
  if (carried.length > 0) {
@@ -1788,27 +1859,39 @@ async function publish(io) {
1788
1859
  let values = defaults;
1789
1860
  const assemble = () => [...entriesFromMap(values), ...carried];
1790
1861
  const edits = /* @__PURE__ */ new Map();
1862
+ const kept = /* @__PURE__ */ new Map();
1863
+ let saved = savedKeys(existing);
1864
+ const disagreeing = () => {
1865
+ const out = detectionDisagreements(
1866
+ values,
1867
+ detected,
1868
+ /* @__PURE__ */ new Set([...edits.keys(), ...kept.keys()])
1869
+ );
1870
+ for (const [key, value] of out) if (valueProblem(value) !== void 0) out.delete(key);
1871
+ return out;
1872
+ };
1791
1873
  const prompt = async (prompter) => {
1792
1874
  const before = values;
1793
- values = await promptEntries(values, prompter);
1875
+ const marked = cardShown ? disagreeing() : /* @__PURE__ */ new Map();
1876
+ values = await promptEntries(values, saved, prompter);
1794
1877
  for (const key of CURATED_KEYS) {
1795
1878
  const after = values.get(key);
1796
1879
  if (after !== before.get(key)) edits.set(key, after);
1797
1880
  }
1881
+ for (const key of marked.keys()) {
1882
+ const value = values.get(key);
1883
+ if (!edits.has(key) && value !== void 0) kept.set(key, value);
1884
+ }
1798
1885
  };
1799
1886
  if (!io.interactive || !io.prompter || io.yes) {
1800
- const over = [...values].find(([, v]) => v.length > MAX_VALUE);
1801
- if (over) {
1802
- console.error(
1803
- message(
1804
- `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>.`
1805
- )
1806
- );
1887
+ const entries = assemble();
1888
+ const refusal = writeRuleRefusal(entries, existing);
1889
+ if (refusal !== void 0) {
1890
+ console.error(message(refusal));
1807
1891
  process.exitCode = 1;
1808
1892
  return;
1809
1893
  }
1810
- const entries = assemble();
1811
- showCard(entries);
1894
+ showCard(entries, /* @__PURE__ */ new Map());
1812
1895
  printPublished(
1813
1896
  await publishProfile(newProfile(handle, entries, extras), cred, { ifMatch }),
1814
1897
  color
@@ -1817,14 +1900,18 @@ async function publish(io) {
1817
1900
  }
1818
1901
  try {
1819
1902
  if (!existing) await prompt(io.prompter);
1903
+ const failsRule = ([, v]) => valueProblem(v) !== void 0;
1820
1904
  for (; ; ) {
1905
+ if ([...values].some(failsRule)) await prompt(io.prompter);
1821
1906
  const entries = assemble();
1822
- showCard(entries);
1907
+ const disagreements = disagreeing();
1908
+ showCard(entries, disagreements);
1909
+ const offerD = disagreements.size > 0;
1823
1910
  const ans = await io.prompter.choice(
1824
1911
  `Publish to ${site}/${sanitizeValue(handle)}?`,
1825
- ["y", "n", "e"],
1912
+ offerD ? ["y", "n", "e", "d"] : ["y", "n", "e"],
1826
1913
  "y",
1827
- "Y/n/e=edit"
1914
+ offerD ? "Y/n/e=edit/d=detected" : "Y/n/e=edit"
1828
1915
  );
1829
1916
  if (ans === "y") {
1830
1917
  try {
@@ -1854,6 +1941,9 @@ async function publish(io) {
1854
1941
  else rebased.set(key, value);
1855
1942
  }
1856
1943
  values = rebased;
1944
+ for (const [key, value] of kept) if (rebased.get(key) !== value) kept.delete(key);
1945
+ cardShown = false;
1946
+ saved = savedKeys(fresh.profile);
1857
1947
  carried = unknownEntries(fresh.profile);
1858
1948
  extras = fresh.profile.extras;
1859
1949
  ifMatch = fresh.etag;
@@ -1874,6 +1964,14 @@ ${ambiguous ? "The publish may not have completed. Your answers are kept." : "No
1874
1964
  console.log(message("Aborted. Nothing published."));
1875
1965
  return;
1876
1966
  }
1967
+ if (ans === "d") {
1968
+ values = new Map(values);
1969
+ for (const [key, value] of disagreements) {
1970
+ values.set(key, value);
1971
+ edits.set(key, value);
1972
+ }
1973
+ continue;
1974
+ }
1877
1975
  await prompt(io.prompter);
1878
1976
  }
1879
1977
  } catch (e) {
@@ -1954,18 +2052,24 @@ async function runSet(target) {
1954
2052
  process.exitCode = 1;
1955
2053
  return;
1956
2054
  }
2055
+ const refusal = writeRuleRefusal(entries, existing);
2056
+ if (refusal !== void 0) {
2057
+ console.error(message(refusal));
2058
+ process.exitCode = 1;
2059
+ return;
2060
+ }
1957
2061
  const res = await publishProfile(newProfile(handle, entries, extras), cred, {
1958
2062
  ifMatch: own?.etag
1959
2063
  });
1960
- const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
2064
+ const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${sanitizeValue(target.value)}.` : `Set extra ${sanitizeValue(target.label)} = ${sanitizeValue(target.value)}.`;
1961
2065
  console.log(message(`${line}${pagePointer(res.handle)}`));
1962
2066
  }
1963
2067
  var PASTE_SAFE_LABEL = /^[\p{L}\p{N} _.+/:-]+$/u;
1964
2068
  function extraHint(existing, label) {
1965
2069
  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";
2070
+ const head2 = eq > 0 ? label.slice(0, eq).trim() : "";
2071
+ if (!head2 || !applyUnset(existing, { kind: "extra", label: head2 }).removed) return "";
2072
+ const shown = PASTE_SAFE_LABEL.test(head2) ? head2 : "Label";
1969
2073
  return `
1970
2074
  (unset takes just the label: ymmv unset --extra "${shown}")`;
1971
2075
  }
@@ -1986,6 +2090,12 @@ async function runUnset(target) {
1986
2090
  console.log(message(line2));
1987
2091
  return;
1988
2092
  }
2093
+ const refusal = writeRuleRefusal(entries, existing);
2094
+ if (refusal !== void 0) {
2095
+ console.error(message(refusal));
2096
+ process.exitCode = 1;
2097
+ return;
2098
+ }
1989
2099
  const res = await publishProfile(newProfile(handle, entries, extras), cred, {
1990
2100
  ifMatch: own?.etag
1991
2101
  });
@@ -2034,10 +2144,10 @@ var SET_USAGE = `usage: ymmv set <key> <value> | ${SET_EXTRA}`;
2034
2144
  var EXTRA_USAGE = `usage: ${SET_EXTRA}`;
2035
2145
  var UNSET_USAGE = `usage: ymmv unset <key> | ${UNSET_EXTRA}`;
2036
2146
  var VIEW_USAGE = "usage: ymmv view <handle>";
2037
- function invalidKeyError(head, hint) {
2147
+ function invalidKeyError(head2, hint) {
2038
2148
  return {
2039
2149
  kind: "error",
2040
- message: `"${sanitizeValue(head)}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
2150
+ message: `"${sanitizeValue(head2)}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
2041
2151
  For anything else, use: ${hint}.`
2042
2152
  };
2043
2153
  }
@@ -2061,9 +2171,15 @@ function valueCapError(value) {
2061
2171
  message: `That value is ${value.length} characters; the cap is ${MAX_VALUE}.`
2062
2172
  };
2063
2173
  }
2174
+ function labelInvisibleError() {
2175
+ return { kind: "error", message: "That label has no visible text." };
2176
+ }
2177
+ function valueInvisibleError() {
2178
+ return { kind: "error", message: "That value has no visible text." };
2179
+ }
2064
2180
  function parseSet(rest) {
2065
- const head = rest[0];
2066
- if (head === "--extra" || head === "-e") {
2181
+ const head2 = rest[0];
2182
+ if (head2 === "--extra" || head2 === "-e") {
2067
2183
  const spec = rest.slice(1).join(" ").trim();
2068
2184
  const eq = spec.indexOf("=");
2069
2185
  if (eq <= 0) return { kind: "error", message: EXTRA_USAGE };
@@ -2071,30 +2187,33 @@ function parseSet(rest) {
2071
2187
  const value2 = spec.slice(eq + 1).trim();
2072
2188
  if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
2073
2189
  if (value2 === "-") return { kind: "unset", target: { kind: "extra", label } };
2190
+ if (!showsVisibleText(label)) return labelInvisibleError();
2191
+ if (!showsVisibleText(value2)) return valueInvisibleError();
2074
2192
  if (label.length > MAX_LABEL) return labelCapError(label);
2075
2193
  if (value2.length > MAX_VALUE) return valueCapError(value2);
2076
2194
  return { kind: "set", target: { kind: "extra", label, value: value2 } };
2077
2195
  }
2078
- if (!head) return { kind: "error", message: SET_USAGE };
2079
- if (!isCuratedKey(head)) return invalidKeyError(head, SET_EXTRA);
2196
+ if (!head2) return { kind: "error", message: SET_USAGE };
2197
+ if (!isCuratedKey(head2)) return invalidKeyError(head2, SET_EXTRA);
2080
2198
  const value = rest.slice(1).join(" ").trim();
2081
- if (!value) return { kind: "error", message: `usage: ymmv set ${head} <value>` };
2082
- if (value === "-") return { kind: "unset", target: { kind: "curated", key: head } };
2199
+ if (!value) return { kind: "error", message: `usage: ymmv set ${head2} <value>` };
2200
+ if (value === "-") return { kind: "unset", target: { kind: "curated", key: head2 } };
2201
+ if (!showsVisibleText(value)) return valueInvisibleError();
2083
2202
  if (value.length > MAX_VALUE) return valueCapError(value);
2084
- return { kind: "set", target: { kind: "curated", key: head, value } };
2203
+ return { kind: "set", target: { kind: "curated", key: head2, value } };
2085
2204
  }
2086
2205
  function parseUnset(rest) {
2087
- const head = rest[0];
2088
- if (head === "--extra" || head === "-e") {
2206
+ const head2 = rest[0];
2207
+ if (head2 === "--extra" || head2 === "-e") {
2089
2208
  const label = rest.slice(1).join(" ").trim();
2090
2209
  if (!label) return { kind: "error", message: `usage: ${UNSET_EXTRA}` };
2091
2210
  if (label.length > MAX_LABEL) return labelCapError(label);
2092
2211
  return { kind: "unset", target: { kind: "extra", label } };
2093
2212
  }
2094
- if (!head) return { kind: "error", message: UNSET_USAGE };
2095
- if (!isCuratedKey(head)) return invalidKeyError(head, UNSET_EXTRA);
2096
- if (rest.length > 1) return { kind: "error", message: `usage: ymmv unset ${head}` };
2097
- return { kind: "unset", target: { kind: "curated", key: head } };
2213
+ if (!head2) return { kind: "error", message: UNSET_USAGE };
2214
+ if (!isCuratedKey(head2)) return invalidKeyError(head2, UNSET_EXTRA);
2215
+ if (rest.length > 1) return { kind: "error", message: `usage: ymmv unset ${head2}` };
2216
+ return { kind: "unset", target: { kind: "curated", key: head2 } };
2098
2217
  }
2099
2218
  function resolveArg(argv) {
2100
2219
  const first = argv[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {