ymmv-cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +45 -8
  2. package/dist/cli.js +363 -83
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -40,18 +40,55 @@ via npm Trusted Publishing, with provenance.
40
40
 
41
41
  ## Commands
42
42
 
43
- - `ymmv` detects, confirms, and publishes (re-run any time to update)
43
+ - `ymmv` detects, confirms, and publishes (re-run any time to update; `ymmv publish` is the same command)
44
44
  - `ymmv <handle>` views a profile, or diffs it against yours when you're logged in
45
45
  - `ymmv set editor Neovim` changes one value
46
- - `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own
46
+ - `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own (`-e` works too)
47
47
  - `ymmv unset editor` removes one value (`ymmv set editor -` works too); `ymmv unset --extra "Keyboard"` removes an extra
48
- - `ymmv delete` removes your profile
48
+ - `ymmv delete` removes your profile (`ymmv delete -y` skips the confirm, for scripts)
49
49
  - `ymmv login` / `ymmv logout` sign in / out
50
-
51
- Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`.
52
-
53
- Color output respects `NO_COLOR`; set `YMMV_API` to point the CLI at a different Worker
54
- (development).
50
+ - `ymmv version` prints the CLI version
51
+
52
+ Values are capped at 256 characters and extra labels at 64; a profile holds up
53
+ to 32 extras.
54
+
55
+ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full contract
56
+ (shape, statuses, caching, CORS):
57
+ <https://github.com/ymmv-fyi/ymmv/blob/main/docs/api.md>.
58
+
59
+ ## Environment variables
60
+
61
+ - `NO_COLOR` disables color output (and `FORCE_COLOR=0`/`false` force-disables it).
62
+ - `YMMV_API` points the CLI at a different Worker (development/staging). Bare origin only.
63
+ - `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
64
+ the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
65
+ `ymmv login` / `ymmv logout` keep acting on the stored login. Viewing (`ymmv <handle>`) also
66
+ keeps using the stored login for the you-side of a diff. The token is sent to the server
67
+ `YMMV_API` selects, so set the two together.
68
+ - `YMMV_HANDLE` names the GitHub username `YMMV_TOKEN` belongs to. Required for `ymmv -y` and
69
+ `ymmv set`/`unset` under an env token (there is no server lookup for it); ignored without
70
+ `YMMV_TOKEN`.
71
+
72
+ ## Publishing from CI
73
+
74
+ `ymmv login` needs a browser, so mint the token on your machine and hand it to CI:
75
+
76
+ 1. Run `ymmv login` locally.
77
+ 2. Copy the `token` value from the token file:
78
+ `~/.config/ymmv/token.json` (Linux), `~/Library/Preferences/ymmv/token.json` (macOS),
79
+ `%APPDATA%\ymmv\Config\token.json` (Windows).
80
+ 3. Set it as a CI secret named `YMMV_TOKEN`, and set `YMMV_HANDLE` to your GitHub username.
81
+ 4. Run `npx ymmv-cli -y` in the job.
82
+
83
+ Two things to know:
84
+
85
+ - `ymmv -y` publishes the merge of your existing profile with what it detects on the machine it
86
+ runs on. Values you already published always win, but curated keys you have never set get the
87
+ CI runner's detected values (its OS, shell, and so on). For targeted updates from CI, prefer
88
+ `ymmv set <key> <value>`.
89
+ - A rejected or revoked `YMMV_TOKEN` fails with an error naming the variable; nothing falls back
90
+ to an interactive login, and the stored login file on the runner (if any) is left untouched.
91
+ `ymmv delete` acts on the account the token is bound to, regardless of `YMMV_HANDLE`.
55
92
 
56
93
  ## License
57
94
 
package/dist/cli.js CHANGED
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // ../shared/dist/caps.js
4
+ var MAX_EXTRAS = 32;
5
+ var MAX_LABEL = 64;
6
+ var MAX_VALUE = 256;
7
+
3
8
  // ../shared/dist/keys.js
4
9
  var CURATED_KEYS = [
5
10
  "editor",
@@ -276,7 +281,8 @@ function parseProfile(raw) {
276
281
  if (!isRecord(raw))
277
282
  throw new ProfileParseError("profile is not an object");
278
283
  if (raw.schema_version !== SCHEMA_VERSION) {
279
- throw new ProfileParseError(`unsupported schema_version: ${String(raw.schema_version)}`);
284
+ const got = String(raw.schema_version);
285
+ throw new ProfileParseError(`unsupported schema_version: ${got.length > 64 ? `${got.slice(0, 64)}\u2026` : got}. Upgrade the ymmv CLI (npm i -g ymmv-cli).`);
280
286
  }
281
287
  if (typeof raw.handle !== "string")
282
288
  throw new ProfileParseError("handle is not a string");
@@ -334,7 +340,17 @@ function parseProfile(raw) {
334
340
 
335
341
  // ../shared/dist/reserved.js
336
342
  var RESERVED_ROUTES = ["404", "api", "login", "logout"];
337
- var CLI_VERBS = ["login", "logout", "set", "unset", "delete", "view", "help"];
343
+ var CLI_VERBS = [
344
+ "login",
345
+ "logout",
346
+ "set",
347
+ "unset",
348
+ "delete",
349
+ "view",
350
+ "help",
351
+ "publish",
352
+ "version"
353
+ ];
338
354
  var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
339
355
  var RESERVED_SET = new Set(RESERVED);
340
356
  function isReserved(handle) {
@@ -382,7 +398,9 @@ function sanitizeValue(value) {
382
398
  }
383
399
  function useColor(env, isTTY) {
384
400
  if (env.NO_COLOR !== void 0) return false;
385
- if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
401
+ if (env.FORCE_COLOR !== void 0) {
402
+ return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
403
+ }
386
404
  if (env.TERM === "dumb") return false;
387
405
  return isTTY;
388
406
  }
@@ -442,13 +460,15 @@ function renderProfile(profile, opts) {
442
460
  const val = (v) => isHttpUrl(v) ? link(v, opts.color) : v;
443
461
  const lines = [
444
462
  "",
445
- ` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`,
446
- ""
463
+ ` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`
447
464
  ];
448
- for (const r of rows) {
449
- lines.push(
450
- r.value === null ? ` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}` : ` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}`
451
- );
465
+ if (rows.length) {
466
+ lines.push("");
467
+ for (const r of rows) {
468
+ lines.push(
469
+ r.value === null ? ` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}` : ` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}`
470
+ );
471
+ }
452
472
  }
453
473
  if (extras.length) {
454
474
  lines.push("");
@@ -553,17 +573,30 @@ function wireText(text) {
553
573
  return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
554
574
  }
555
575
  var INVISIBLE_RE = new RegExp("\\p{Default_Ignorable_Code_Point}", "gu");
556
- async function serverMessage(res) {
576
+ async function wireBody(res) {
557
577
  try {
558
- const body = await res.json();
578
+ return await res.text();
579
+ } catch (err) {
580
+ if (isTimeoutError(err)) throw err;
581
+ return "";
582
+ }
583
+ }
584
+ function wireErrorBody(raw) {
585
+ try {
586
+ const body = JSON.parse(raw);
587
+ const out = {};
588
+ if (typeof body?.error === "string") out.slug = body.error;
559
589
  if (typeof body?.message === "string") {
560
590
  const clean = wireText(body.message).trim();
561
- if (clean.replace(INVISIBLE_RE, "").trim()) return clean;
591
+ if (clean.replace(INVISIBLE_RE, "").trim()) out.message = clean;
562
592
  }
563
- } catch (err) {
564
- if (isTimeoutError(err)) throw err;
593
+ return out;
594
+ } catch {
595
+ return {};
565
596
  }
566
- return void 0;
597
+ }
598
+ async function serverMessage(res) {
599
+ return wireErrorBody(await wireBody(res)).message;
567
600
  }
568
601
  function withRetryHint(msg, res) {
569
602
  const retry = res.headers.get("retry-after");
@@ -574,6 +607,12 @@ function displayError(err) {
574
607
  const text = err instanceof Error ? err.message : String(err);
575
608
  return text.split(/\r?\n/).map((line) => sanitizeValue(line)).join("\n");
576
609
  }
610
+ var NetworkError = class extends Error {
611
+ constructor(message2, options) {
612
+ super(message2, options);
613
+ this.name = "NetworkError";
614
+ }
615
+ };
577
616
  async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
578
617
  try {
579
618
  return await fetchFn(url, {
@@ -581,7 +620,7 @@ async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
581
620
  signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
582
621
  });
583
622
  } catch (err) {
584
- throw new Error(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
623
+ throw new NetworkError(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
585
624
  cause: err
586
625
  });
587
626
  }
@@ -591,7 +630,51 @@ async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
591
630
  import { readFileSync as readFileSync2 } from "fs";
592
631
 
593
632
  // src/config.ts
594
- var BASE = (process.env.YMMV_API ?? "https://ymmv.fyi").replace(/\/+$/, "");
633
+ function normalizeBase(raw) {
634
+ return raw.replace(/\/+$/, "");
635
+ }
636
+ var BASE = normalizeBase(process.env.YMMV_API || "https://ymmv.fyi");
637
+ function baseProblem(raw = process.env.YMMV_API) {
638
+ if (raw === void 0 || raw === "") return null;
639
+ const shown = `YMMV_API is set to "${sanitizeValue(raw)}"`;
640
+ if (/\s/.test(raw)) return `${shown} which contains whitespace. Remove it.`;
641
+ const base = normalizeBase(raw);
642
+ let url;
643
+ try {
644
+ url = new URL(base);
645
+ } catch {
646
+ return `${shown} which is not a full URL. Use a bare origin like https://ymmv.fyi (the http:// or https:// scheme is required).`;
647
+ }
648
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
649
+ return `${shown} which is not an http or https URL. Use a bare origin like https://ymmv.fyi.`;
650
+ }
651
+ if (url.username || url.password) {
652
+ return `${shown} which contains credentials. Use a bare origin like https://ymmv.fyi.`;
653
+ }
654
+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
655
+ return `${shown} which is not a bare origin. Drop the path (the Worker's redirects are root-absolute, so a path-mounted base breaks): use just the scheme and host, like https://ymmv.fyi.`;
656
+ }
657
+ if (base !== url.origin) {
658
+ return `${shown} which is not in canonical form. Use exactly the scheme and host, like https://ymmv.fyi.`;
659
+ }
660
+ return null;
661
+ }
662
+ function credentialEnvProblem(rawToken = process.env.YMMV_TOKEN, rawHandle = process.env.YMMV_HANDLE) {
663
+ if (rawToken === void 0 || rawToken === "") return null;
664
+ if (!/^[\x21-\x7E]+$/.test(rawToken)) {
665
+ return "YMMV_TOKEN contains whitespace, control, or non-ASCII characters. Set it to the exact token value.";
666
+ }
667
+ if (rawHandle !== void 0 && rawHandle !== "") {
668
+ const shown = `YMMV_HANDLE is set to "${sanitizeValue(rawHandle)}"`;
669
+ if (!isValidHandle(rawHandle)) {
670
+ return `${shown} which is not a valid GitHub username. Set it to the handle bound to YMMV_TOKEN.`;
671
+ }
672
+ if (isReserved(rawHandle)) {
673
+ return `${shown} which is a reserved word, so no handle can be bound to it.`;
674
+ }
675
+ }
676
+ return null;
677
+ }
595
678
 
596
679
  // src/auth-http.ts
597
680
  async function bodyJson(res) {
@@ -631,7 +714,16 @@ async function mintYmmvToken(accessToken) {
631
714
  );
632
715
  }
633
716
  const body = await bodyJson(res) ?? {};
634
- throw new Error(`login failed: ${res.status} ${wireText(body.error ?? "")}`.trim());
717
+ const slug = typeof body.error === "string" ? body.error : "";
718
+ if (res.status === 401 && slug === "github_auth_failed") {
719
+ throw new Error("GitHub rejected the authorization. Run `ymmv login` to try again.");
720
+ }
721
+ if (res.status === 500 && slug === "internal_error") {
722
+ throw new Error(
723
+ "The server hit an error minting your login. Run `ymmv login` again shortly."
724
+ );
725
+ }
726
+ throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
635
727
  }
636
728
  const data = await bodyJson(res);
637
729
  if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
@@ -690,38 +782,49 @@ async function saveToken(data) {
690
782
  throw e;
691
783
  }
692
784
  }
693
- async function loadToken() {
694
- let raw;
785
+ async function readTokenFile() {
695
786
  try {
696
- raw = await readFile(tokenFilePath(), "utf8");
787
+ const parsed = JSON.parse(await readFile(tokenFilePath(), "utf8"));
788
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
697
789
  } catch {
698
790
  return null;
699
791
  }
700
- let parsed;
701
- try {
702
- parsed = JSON.parse(raw);
703
- } catch {
792
+ }
793
+ async function loadToken() {
794
+ const parsed = await readTokenFile();
795
+ if (!parsed || parsed.base !== BASE || typeof parsed.token !== "string" || parsed.token === "" || parsed.handle !== null && typeof parsed.handle !== "string") {
704
796
  return null;
705
797
  }
706
- if (parsed.base !== BASE || typeof parsed.token !== "string") return null;
707
798
  return parsed;
708
799
  }
800
+ async function peekCredential() {
801
+ const parsed = await readTokenFile();
802
+ return parsed && typeof parsed.base === "string" && typeof parsed.token === "string" && parsed.token !== "" ? { base: parsed.base, token: parsed.token } : null;
803
+ }
804
+ async function loadCredential() {
805
+ const envToken = process.env.YMMV_TOKEN || "";
806
+ if (envToken !== "") {
807
+ return { base: BASE, token: envToken, handle: process.env.YMMV_HANDLE || null, source: "env" };
808
+ }
809
+ const stored = await loadToken();
810
+ return stored ? { ...stored, source: "file" } : null;
811
+ }
709
812
  async function deleteToken() {
710
813
  await rm(tokenFilePath(), { force: true });
711
814
  }
712
815
  async function peekBase() {
713
- try {
714
- const parsed = JSON.parse(await readFile(tokenFilePath(), "utf8"));
715
- return typeof parsed.base === "string" ? parsed.base : null;
716
- } catch {
717
- return null;
718
- }
816
+ const parsed = await readTokenFile();
817
+ return typeof parsed?.base === "string" ? parsed.base : null;
719
818
  }
720
819
 
721
820
  // src/device-flow.ts
722
821
  var DEVICE_CODE_URL = "https://github.com/login/device/code";
723
822
  var TOKEN_URL = "https://github.com/login/oauth/access_token";
724
823
  var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
824
+ var DEFAULT_POLL_INTERVAL_S = 5;
825
+ function usableInterval(v) {
826
+ return typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 900;
827
+ }
725
828
  async function requestDeviceCode(deps = {}) {
726
829
  const doFetch = deps.fetch ?? globalThis.fetch;
727
830
  const res = await safeFetch(
@@ -741,7 +844,10 @@ async function requestDeviceCode(deps = {}) {
741
844
  if (isTimeoutError(err)) throw err;
742
845
  return null;
743
846
  });
744
- if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.expires_in !== "number") {
847
+ if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || // expires_in gets the same rigor as interval: NaN makes the deadline NaN (instant false
848
+ // "expired"), Infinity/1e300 make it unreachable (a middlebox feeding parseable
849
+ // authorization_pending bodies would hold the login forever - the deadline is the only exit).
850
+ typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || data.expires_in > 86400 || data.interval !== void 0 && !usableInterval(data.interval)) {
745
851
  throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
746
852
  }
747
853
  return data;
@@ -750,7 +856,7 @@ async function pollForToken(dc, deps = {}) {
750
856
  const doFetch = deps.fetch ?? globalThis.fetch;
751
857
  const sleep = deps.sleep ?? realSleep;
752
858
  const now = deps.now ?? Date.now;
753
- let interval = dc.interval || 5;
859
+ let interval = usableInterval(dc.interval) ? dc.interval : DEFAULT_POLL_INTERVAL_S;
754
860
  const deadline = now() + dc.expires_in * 1e3;
755
861
  let transientFailures = 0;
756
862
  let lastCause = "";
@@ -802,7 +908,7 @@ async function pollForToken(dc, deps = {}) {
802
908
  case "authorization_pending":
803
909
  break;
804
910
  case "slow_down":
805
- interval = Math.max(tok.interval ?? 0, interval + 5);
911
+ interval = usableInterval(tok.interval) ? Math.max(tok.interval, interval + DEFAULT_POLL_INTERVAL_S) : interval + DEFAULT_POLL_INTERVAL_S;
806
912
  break;
807
913
  case "access_denied":
808
914
  throw new Error("Authorization denied. Run `ymmv login` to try again.");
@@ -820,9 +926,24 @@ async function login(deps = {}) {
820
926
  "Device login needs an interactive terminal. Run `ymmv login` in a real terminal (a piped or CI shell can't complete the GitHub device flow)."
821
927
  );
822
928
  }
823
- const dc = await requestDeviceCode(deps);
929
+ if (process.env.YMMV_TOKEN) {
930
+ console.error(
931
+ message(
932
+ "YMMV_TOKEN is set and takes precedence over stored logins. This login will be saved but not used until you unset it."
933
+ )
934
+ );
935
+ }
936
+ const prior = await peekCredential();
824
937
  const color = colorEnabled();
825
938
  const c = palette(color);
939
+ if (prior && prior.base !== BASE) {
940
+ console.error(
941
+ message(
942
+ `You're logged in to ${sanitizeValue(prior.base)}. Logging in here replaces that stored token. To revoke it first, set YMMV_API to that server and run \`ymmv logout\`.`
943
+ )
944
+ );
945
+ }
946
+ const dc = await requestDeviceCode(deps);
826
947
  const verifyUri = /^https:\/\/github\.com\//.test(dc.verification_uri) ? link(dc.verification_uri, color) : sanitizeValue(dc.verification_uri);
827
948
  console.log(
828
949
  message(
@@ -832,6 +953,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
832
953
  );
833
954
  const accessToken = await pollForToken(dc, deps);
834
955
  const { token, handle } = await mintYmmvToken(accessToken);
956
+ const replaced = await peekCredential();
835
957
  try {
836
958
  await saveToken({ token, handle });
837
959
  } catch (e) {
@@ -839,6 +961,13 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
839
961
  });
840
962
  throw e;
841
963
  }
964
+ if (replaced && replaced.base === BASE && replaced.token !== token) {
965
+ try {
966
+ await revokeYmmvToken(replaced.token);
967
+ } catch {
968
+ console.error(message(`${c.faint}(couldn't revoke the previous session's token)${c.reset}`));
969
+ }
970
+ }
842
971
  console.log(
843
972
  message(
844
973
  handle ? `Logged in as ${handle}.` : "Logged in. No handle bound (your GitHub username is a reserved word)."
@@ -847,14 +976,21 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
847
976
  }
848
977
 
849
978
  // src/api.ts
979
+ var PublishRefusal = class extends Error {
980
+ constructor(msg) {
981
+ super(msg);
982
+ this.name = "PublishRefusal";
983
+ }
984
+ };
985
+ var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (revoked or expired).";
850
986
  async function rateLimitMessage(res) {
851
987
  return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
852
988
  }
853
989
  async function ensureLogin() {
854
- const existing = await loadToken();
990
+ const existing = await loadCredential();
855
991
  if (existing) return existing;
856
992
  await login();
857
- const fresh = await loadToken();
993
+ const fresh = await loadCredential();
858
994
  if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
859
995
  return fresh;
860
996
  }
@@ -874,35 +1010,60 @@ async function publishProfile(profile) {
874
1010
  );
875
1011
  let cred = await ensureLogin();
876
1012
  if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
877
- throw new Error(
1013
+ throw new PublishRefusal(
878
1014
  "The stored login changed while this command was running. Re-run it under the current account."
879
1015
  );
880
1016
  }
881
1017
  let res = await send(cred);
882
1018
  if (res.status === 401 || res.status === 409) {
1019
+ if (cred.source === "env") {
1020
+ throw new PublishRefusal(
1021
+ 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`."
1022
+ );
1023
+ }
883
1024
  const was401 = res.status === 401;
1025
+ console.log(
1026
+ message(
1027
+ was401 ? "Session expired. Logging in again to retry the publish." : "The server no longer recognizes your handle. Logging in again to retry the publish."
1028
+ )
1029
+ );
884
1030
  if (was401) await deleteToken();
885
1031
  await login();
886
1032
  cred = await ensureLogin();
887
1033
  if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
888
1034
  const bound = sanitizeValue(cred.handle ?? "");
889
- throw new Error(
1035
+ throw new PublishRefusal(
890
1036
  was401 ? `The re-login bound a different account ("${bound}", not "${sanitizeValue(profile.handle)}"). Nothing was published. Re-run under the account you meant.` : `Your login now binds "${bound}". Nothing was published. Re-run the command to publish under it.`
891
1037
  );
892
1038
  }
893
1039
  res = await send(cred);
894
- if (res.status === 401) throw new Error("Authentication failed. Run `ymmv login`.");
1040
+ if (res.status === 401) throw new PublishRefusal("Authentication failed. Run `ymmv login`.");
895
1041
  if (res.status === 409) {
1042
+ const { slug, message: srvMsg } = wireErrorBody(await wireBody(res));
1043
+ if (slug === "handle_not_bound") {
1044
+ throw new PublishRefusal(
1045
+ "The server still refuses this handle after a fresh login. Wait a moment and re-run the command."
1046
+ );
1047
+ }
896
1048
  throw new Error(
897
- await serverMessage(res) ?? "that handle is taken by another account (your GitHub handle may have been reused)."
1049
+ srvMsg ?? "that handle is taken by another account (your GitHub handle may have been reused)."
898
1050
  );
899
1051
  }
900
1052
  }
901
1053
  if (res.status === 429) throw new Error(await rateLimitMessage(res));
902
1054
  if (!res.ok) {
903
- throw new Error(`publish failed: ${res.status} ${wireText(await res.text())}`);
1055
+ const raw = await wireBody(res);
1056
+ const parsed = wireErrorBody(raw);
1057
+ if (res.status === 400 && parsed.slug === "unsupported_schema_version") {
1058
+ throw new PublishRefusal(parsed.message ?? `publish failed: ${res.status} ${wireText(raw)}`);
1059
+ }
1060
+ throw new Error(parsed.message ?? `publish failed: ${res.status} ${wireText(raw)}`);
1061
+ }
1062
+ let data = {};
1063
+ try {
1064
+ data = await res.json();
1065
+ } catch {
904
1066
  }
905
- const data = await res.json();
906
1067
  const shown = typeof data.handle === "string" ? sanitizeValue(data.handle) : profile.handle;
907
1068
  return { handle: shown, url: `${BASE}/${shown}` };
908
1069
  }
@@ -914,8 +1075,7 @@ async function fetchProfileJson(handle) {
914
1075
  }
915
1076
  return parseProfile(await res.json());
916
1077
  }
917
- async function deleteProfile() {
918
- const cred = await ensureLogin();
1078
+ async function deleteProfile(cred) {
919
1079
  const res = await safeFetch(
920
1080
  `${BASE}/api/v1/profile`,
921
1081
  {
@@ -926,11 +1086,14 @@ async function deleteProfile() {
926
1086
  BASE
927
1087
  );
928
1088
  if (res.status === 401) {
929
- throw new Error("Session expired. Run `ymmv login`, then `ymmv delete` again.");
1089
+ throw new Error(
1090
+ cred.source === "env" ? `${ENV_TOKEN_REJECTED} Update YMMV_TOKEN and run \`ymmv delete\` again.` : "Session expired. Run `ymmv login`, then `ymmv delete` again."
1091
+ );
930
1092
  }
931
1093
  if (res.status === 429) throw new Error(await rateLimitMessage(res));
932
1094
  if (!res.ok) {
933
- throw new Error(`delete failed: ${res.status} ${wireText(await res.text())}`);
1095
+ const raw = await wireBody(res);
1096
+ throw new Error(wireErrorBody(raw).message ?? `delete failed: ${res.status} ${wireText(raw)}`);
934
1097
  }
935
1098
  }
936
1099
 
@@ -1299,7 +1462,7 @@ function requireHandle(cred) {
1299
1462
  if (cred.handle) return cred.handle;
1300
1463
  console.error(
1301
1464
  message(
1302
- "Your GitHub username is a reserved word, so no handle is bound. Rename on GitHub, then run `ymmv login` again."
1465
+ 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."
1303
1466
  )
1304
1467
  );
1305
1468
  process.exitCode = 1;
@@ -1334,9 +1497,21 @@ async function promptEntries(defaults, prompter) {
1334
1497
  console.log(message(`${c.faint}Enter to keep, "-" to clear${c.reset}`));
1335
1498
  const chosen = /* @__PURE__ */ new Map();
1336
1499
  for (const key of CURATED_KEYS) {
1337
- const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
1338
- const value = answer === "-" ? "" : answer;
1339
- if (value) chosen.set(key, value);
1500
+ for (; ; ) {
1501
+ const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
1502
+ const value = answer === "-" ? "" : answer;
1503
+ if (value.length > MAX_VALUE) {
1504
+ const isDefault = value === (defaults.get(key) ?? "").trim();
1505
+ console.log(
1506
+ message(
1507
+ `${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}`
1508
+ )
1509
+ );
1510
+ continue;
1511
+ }
1512
+ if (value) chosen.set(key, value);
1513
+ break;
1514
+ }
1340
1515
  }
1341
1516
  return chosen;
1342
1517
  }
@@ -1391,6 +1566,16 @@ async function publish(io) {
1391
1566
  let values = defaults;
1392
1567
  const assemble = () => [...entriesFromMap(values), ...carried];
1393
1568
  if (!io.interactive || !io.prompter || io.yes) {
1569
+ const over = [...values].find(([, v]) => v.length > MAX_VALUE);
1570
+ if (over) {
1571
+ console.error(
1572
+ message(
1573
+ `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>.`
1574
+ )
1575
+ );
1576
+ process.exitCode = 1;
1577
+ return;
1578
+ }
1394
1579
  const entries = assemble();
1395
1580
  showCard(entries);
1396
1581
  printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
@@ -1408,8 +1593,20 @@ async function publish(io) {
1408
1593
  "Y/n/e=edit"
1409
1594
  );
1410
1595
  if (ans === "y") {
1411
- printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1412
- return;
1596
+ try {
1597
+ printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1598
+ return;
1599
+ } catch (e) {
1600
+ if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
1601
+ const ambiguous = e instanceof NetworkError || isTimeoutError(e);
1602
+ console.error(
1603
+ message(
1604
+ `${displayError(e)}
1605
+ ${ambiguous ? "The publish may not have completed. Your answers are kept." : "Nothing was published. Your answers are kept."}`
1606
+ )
1607
+ );
1608
+ continue;
1609
+ }
1413
1610
  }
1414
1611
  if (ans === "n") {
1415
1612
  console.log(message("Aborted. Nothing published."));
@@ -1436,7 +1633,11 @@ async function view(handle) {
1436
1633
  }
1437
1634
  const cred = await loadToken();
1438
1635
  if (cred?.handle) {
1439
- const mine = await fetchProfileJson(cred.handle).catch(() => null);
1636
+ let mineFailed = false;
1637
+ const mine = await fetchProfileJson(cred.handle).catch(() => {
1638
+ mineFailed = true;
1639
+ return null;
1640
+ });
1440
1641
  if (mine && mine.handle.toLowerCase() !== theirs.handle.toLowerCase()) {
1441
1642
  console.log(
1442
1643
  renderDiff(diff(mine, theirs), { color: c, theirsLabel: theirs.handle, mineLabel: "you" })
@@ -1445,7 +1646,12 @@ async function view(handle) {
1445
1646
  }
1446
1647
  if (!mine) {
1447
1648
  console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
1448
- console.log(nudge(c));
1649
+ if (mineFailed) {
1650
+ const codes = palette(c);
1651
+ console.error(message(`${codes.faint}(couldn't load your profile to diff)${codes.reset}`));
1652
+ } else {
1653
+ console.log(nudge(c));
1654
+ }
1449
1655
  return;
1450
1656
  }
1451
1657
  }
@@ -1458,6 +1664,15 @@ async function runSet(target) {
1458
1664
  const existing = await fetchProfileJson(handle);
1459
1665
  assertHandleUnchanged(existing, handle);
1460
1666
  const { entries, extras } = applySet(existing, target);
1667
+ if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
1668
+ console.error(
1669
+ message(
1670
+ `Your profile already has ${MAX_EXTRAS} extras; that's the cap. Remove one first: ymmv unset --extra "Label".`
1671
+ )
1672
+ );
1673
+ process.exitCode = 1;
1674
+ return;
1675
+ }
1461
1676
  const res = await publishProfile(newProfile(handle, entries, extras));
1462
1677
  const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
1463
1678
  console.log(message(`${line}${pagePointer(res.handle)}`));
@@ -1487,7 +1702,7 @@ async function runUnset(target) {
1487
1702
  }
1488
1703
  async function runDelete(io) {
1489
1704
  const cred = await ensureLogin();
1490
- const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
1705
+ const target = cred.source === "env" ? "the profile bound to YMMV_TOKEN" : cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
1491
1706
  if (!io.yes) {
1492
1707
  if (!io.interactive || !io.prompter) {
1493
1708
  console.error(
@@ -1515,8 +1730,8 @@ ${message("Cancelled. Nothing deleted.")}`);
1515
1730
  return;
1516
1731
  }
1517
1732
  }
1518
- await deleteProfile();
1519
- await deleteToken();
1733
+ await deleteProfile(cred);
1734
+ if (cred.source === "file") await deleteToken();
1520
1735
  console.log(message(`Deleted ${target}. Run \`ymmv\` to publish again.`));
1521
1736
  }
1522
1737
 
@@ -1526,15 +1741,33 @@ var UNSET_EXTRA = 'ymmv unset --extra "Label"';
1526
1741
  var SET_USAGE = `usage: ymmv set <key> <value> | ${SET_EXTRA}`;
1527
1742
  var EXTRA_USAGE = `usage: ${SET_EXTRA}`;
1528
1743
  var UNSET_USAGE = `usage: ymmv unset <key> | ${UNSET_EXTRA}`;
1744
+ var VIEW_USAGE = "usage: ymmv view <handle>";
1529
1745
  function invalidKeyError(head, hint) {
1530
1746
  return {
1531
1747
  kind: "error",
1532
- message: `"${head}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
1748
+ message: `"${sanitizeValue(head)}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
1533
1749
  For anything else, use: ${hint}.`
1534
1750
  };
1535
1751
  }
1536
- function hasYes(args) {
1537
- return args.includes("-y") || args.includes("--yes");
1752
+ function noArgs(verb, rest) {
1753
+ return rest.length === 0 ? { kind: verb } : { kind: "error", message: `usage: ymmv ${verb}` };
1754
+ }
1755
+ function yesOnly(usage, rest, make) {
1756
+ if (rest.length === 0) return make(false);
1757
+ if (rest.length === 1 && (rest[0] === "-y" || rest[0] === "--yes")) return make(true);
1758
+ return { kind: "error", message: usage };
1759
+ }
1760
+ function labelCapError(label) {
1761
+ return {
1762
+ kind: "error",
1763
+ message: `That label is ${label.length} characters; the cap is ${MAX_LABEL}.`
1764
+ };
1765
+ }
1766
+ function valueCapError(value) {
1767
+ return {
1768
+ kind: "error",
1769
+ message: `That value is ${value.length} characters; the cap is ${MAX_VALUE}.`
1770
+ };
1538
1771
  }
1539
1772
  function parseSet(rest) {
1540
1773
  const head = rest[0];
@@ -1546,6 +1779,8 @@ function parseSet(rest) {
1546
1779
  const value2 = spec.slice(eq + 1).trim();
1547
1780
  if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
1548
1781
  if (value2 === "-") return { kind: "unset", target: { kind: "extra", label } };
1782
+ if (label.length > MAX_LABEL) return labelCapError(label);
1783
+ if (value2.length > MAX_VALUE) return valueCapError(value2);
1549
1784
  return { kind: "set", target: { kind: "extra", label, value: value2 } };
1550
1785
  }
1551
1786
  if (!head) return { kind: "error", message: SET_USAGE };
@@ -1553,6 +1788,7 @@ function parseSet(rest) {
1553
1788
  const value = rest.slice(1).join(" ").trim();
1554
1789
  if (!value) return { kind: "error", message: `usage: ymmv set ${head} <value>` };
1555
1790
  if (value === "-") return { kind: "unset", target: { kind: "curated", key: head } };
1791
+ if (value.length > MAX_VALUE) return valueCapError(value);
1556
1792
  return { kind: "set", target: { kind: "curated", key: head, value } };
1557
1793
  }
1558
1794
  function parseUnset(rest) {
@@ -1575,40 +1811,69 @@ function parseUnset(rest) {
1575
1811
  }
1576
1812
  function resolveArg(argv) {
1577
1813
  const first = argv[0];
1814
+ const rest = argv.slice(1);
1578
1815
  if (first === "-h" || first === "--help" || first === "help") return { kind: "help" };
1579
- if (first === "-V" || first === "-v" || first === "--version") return { kind: "version" };
1816
+ if (first === "-V" || first === "-v" || first === "--version" || first === "version") {
1817
+ return rest.length === 0 ? { kind: "version" } : { kind: "error", message: "usage: ymmv version" };
1818
+ }
1580
1819
  if (first === void 0) return { kind: "publish", yes: false };
1581
- if (first === "-y" || first === "--yes") return { kind: "publish", yes: true };
1582
- if (first === "login") return { kind: "login" };
1583
- if (first === "logout") return { kind: "logout" };
1584
- if (first === "delete") return { kind: "delete", yes: hasYes(argv.slice(1)) };
1585
- if (first === "set") return parseSet(argv.slice(1));
1586
- if (first === "unset") return parseUnset(argv.slice(1));
1820
+ if (first === "-y" || first === "--yes") {
1821
+ if (rest.length > 0) {
1822
+ const example = rest[0] === "delete" || rest[0] === "publish" ? rest[0] : "publish";
1823
+ return {
1824
+ kind: "error",
1825
+ message: `Put ${first} after the command: ymmv ${example} -y. A bare ymmv -y publishes without prompts.`
1826
+ };
1827
+ }
1828
+ return { kind: "publish", yes: true };
1829
+ }
1830
+ if (first === "login" || first === "logout") return noArgs(first, rest);
1831
+ if (first === "publish") {
1832
+ return yesOnly("usage: ymmv publish [-y]", rest, (yes) => ({ kind: "publish", yes }));
1833
+ }
1834
+ if (first === "delete") {
1835
+ return yesOnly(
1836
+ "usage: ymmv delete [-y] (deletes your own profile; takes no handle)",
1837
+ rest,
1838
+ (yes) => ({ kind: "delete", yes })
1839
+ );
1840
+ }
1841
+ if (first === "set") return parseSet(rest);
1842
+ if (first === "unset") return parseUnset(rest);
1587
1843
  if (first === "view") {
1588
- const handle = argv[1];
1589
- if (!handle) return { kind: "error", message: "usage: ymmv view <handle>" };
1844
+ const handle = rest[0];
1845
+ if (!handle) return { kind: "error", message: VIEW_USAGE };
1590
1846
  if (!isValidHandle(handle)) {
1591
- return { kind: "error", message: `"${handle}" is not a valid GitHub handle.` };
1847
+ return { kind: "error", message: `"${sanitizeValue(handle)}" is not a valid GitHub handle.` };
1592
1848
  }
1593
1849
  if (isReserved(handle)) return reservedError(handle);
1850
+ if (rest.length > 1) return { kind: "error", message: VIEW_USAGE };
1594
1851
  return { kind: "view", handle };
1595
1852
  }
1596
1853
  if (first.startsWith("-")) {
1597
- return { kind: "error", message: `Unknown option "${first}". Run \`ymmv help\`.` };
1854
+ return {
1855
+ kind: "error",
1856
+ message: `Unknown option "${sanitizeValue(first)}". Run \`ymmv help\`.`
1857
+ };
1598
1858
  }
1599
1859
  if (!isValidHandle(first)) {
1600
1860
  return {
1601
1861
  kind: "error",
1602
- message: `"${first}" is not a valid GitHub handle. Run \`ymmv help\`.`
1862
+ message: `"${sanitizeValue(first)}" is not a valid GitHub handle. Run \`ymmv help\`.`
1603
1863
  };
1604
1864
  }
1605
- if (isReserved(first)) return reservedError(first);
1865
+ if (isReserved(first)) return reservedError(first, true);
1866
+ if (rest.length > 0) {
1867
+ return { kind: "error", message: `Unexpected arguments after "${first}". Run \`ymmv help\`.` };
1868
+ }
1606
1869
  return { kind: "view", handle: first };
1607
1870
  }
1608
- function reservedError(handle) {
1871
+ function reservedError(handle, hintVerbs = false) {
1872
+ const verb = handle.toLowerCase();
1873
+ const hint = hintVerbs && CLI_VERBS.includes(verb) ? ` Did you mean: ymmv ${verb}?` : "";
1609
1874
  return {
1610
1875
  kind: "error",
1611
- message: `"${handle}" is a reserved name; it can't have a profile.`
1876
+ message: `"${handle}" is a reserved name; it can't have a profile.${hint}`
1612
1877
  };
1613
1878
  }
1614
1879
 
@@ -1621,12 +1886,12 @@ ${c.faint}Usage:${c.reset}
1621
1886
  ymmv <handle> view a profile; logged in, see the diff vs yours
1622
1887
  ymmv view <handle> explicit view (same as ymmv <handle>)
1623
1888
  ymmv set <key> <value> set one curated key
1624
- ymmv set --extra "L=V" set a free-form extra
1889
+ ymmv set --extra "L=V" set a free-form extra (-e works too)
1625
1890
  ymmv unset <key> remove one curated key (ymmv set <key> - works too)
1626
1891
  ymmv unset --extra "L" remove a free-form extra
1627
- ymmv delete delete your profile (permanent)
1892
+ ymmv delete [-y] delete your profile (permanent; -y skips the confirm)
1628
1893
  ymmv login | logout GitHub device-flow auth
1629
- ymmv help | --version
1894
+ ymmv help | version
1630
1895
 
1631
1896
  ${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser, window-manager,
1632
1897
  font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
@@ -1636,7 +1901,7 @@ async function logout() {
1636
1901
  const otherBase = await peekBase();
1637
1902
  console.log(
1638
1903
  message(
1639
- otherBase && otherBase !== BASE ? `Not logged in to ${BASE} (a token for ${otherBase} exists; set YMMV_API to that to log out of it).` : "Not logged in."
1904
+ otherBase && otherBase !== BASE ? `Not logged in to ${BASE} (a token for ${sanitizeValue(otherBase)} exists; set YMMV_API to that to log out of it).` : "Not logged in."
1640
1905
  )
1641
1906
  );
1642
1907
  return;
@@ -1644,10 +1909,10 @@ async function logout() {
1644
1909
  let revoked;
1645
1910
  try {
1646
1911
  revoked = await revokeYmmvToken(stored.token);
1647
- } catch {
1912
+ } catch (e) {
1648
1913
  console.error(
1649
1914
  message(
1650
- "Couldn't reach the server to revoke. Your token is still active. Run `ymmv logout` again when connected."
1915
+ e instanceof NetworkError || isTimeoutError(e) ? "Couldn't reach the server to revoke. Your token is still active. Run `ymmv logout` again when connected." : "The server didn't confirm the revoke. Your token is still active. Run `ymmv logout` again shortly."
1651
1916
  )
1652
1917
  );
1653
1918
  process.exitCode = 1;
@@ -1675,6 +1940,14 @@ async function interactive(run, yes) {
1675
1940
  }
1676
1941
  async function main(argv) {
1677
1942
  const cmd = resolveArg(argv);
1943
+ if (cmd.kind !== "logout") {
1944
+ const problem = baseProblem() ?? credentialEnvProblem();
1945
+ if (problem) {
1946
+ console.error(message(problem));
1947
+ process.exitCode = 1;
1948
+ return;
1949
+ }
1950
+ }
1678
1951
  switch (cmd.kind) {
1679
1952
  case "publish":
1680
1953
  await interactive(publish, cmd.yes);
@@ -1699,6 +1972,13 @@ async function main(argv) {
1699
1972
  }
1700
1973
  case "logout":
1701
1974
  await logout();
1975
+ if (process.env.YMMV_TOKEN) {
1976
+ console.error(
1977
+ message(
1978
+ "Note: YMMV_TOKEN is set and still authenticates API calls. Unset it to stop using that token."
1979
+ )
1980
+ );
1981
+ }
1702
1982
  break;
1703
1983
  case "help":
1704
1984
  console.log(help(palette(colorEnabled())));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {