ymmv-cli 0.7.0 → 0.9.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 +59 -10
  2. package/dist/cli.js +669 -95
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -9,10 +9,13 @@ clean page at `ymmv.fyi/<handle>` in about 10 seconds. See a live one:
9
9
  ![Running npx ymmv-cli to detect, confirm, and publish a dev stack to a live ymmv.fyi page](https://raw.githubusercontent.com/ymmv-fyi/ymmv/main/docs/demo.gif)
10
10
 
11
11
  ```sh
12
- npx ymmv-cli # detect your stack, confirm, go live at ymmv.fyi/<you>
13
- npx ymmv-cli bardisty # view someone's stack in the terminal
12
+ npx ymmv-cli@latest # detect your stack, confirm, go live at ymmv.fyi/<you>
13
+ npx ymmv-cli@latest bardisty # view someone's stack in the terminal
14
14
  ```
15
15
 
16
+ (`@latest` makes npx look for the newest release; without a version spec, npx can pin you
17
+ to whatever it downloaded first.)
18
+
16
19
  Viewing someone while you're logged in diffs their stack against yours:
17
20
 
18
21
  ```
@@ -33,6 +36,9 @@ Install once for the short `ymmv` command:
33
36
  npm i -g ymmv-cli
34
37
  ```
35
38
 
39
+ When a newer release exists, the CLI mentions it after a command (interactive terminals only)
40
+ and `ymmv update` upgrades a global install in place.
41
+
36
42
  First run includes a one-time GitHub sign-in. Works on macOS, Linux, Windows,
37
43
  and WSL. Nothing is published until you confirm (detection only pre-fills), and
38
44
  `ymmv delete` removes everything. Releases are published from GitHub Actions
@@ -40,18 +46,61 @@ via npm Trusted Publishing, with provenance.
40
46
 
41
47
  ## Commands
42
48
 
43
- - `ymmv` detects, confirms, and publishes (re-run any time to update)
49
+ - `ymmv` detects, confirms, and publishes (re-run any time to update; `ymmv publish` is the same command)
44
50
  - `ymmv <handle>` views a profile, or diffs it against yours when you're logged in
45
51
  - `ymmv set editor Neovim` changes one value
46
- - `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own
52
+ - `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own (`-e` works too)
47
53
  - `ymmv unset editor` removes one value (`ymmv set editor -` works too); `ymmv unset --extra "Keyboard"` removes an extra
48
- - `ymmv delete` removes your profile
54
+ - `ymmv delete` removes your profile (`ymmv delete -y` skips the confirm, for scripts)
49
55
  - `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).
56
+ - `ymmv update` updates the CLI to the latest release (runs the matching upgrade for npm, pnpm,
57
+ and bun global installs; via npx it prints the invocation to use instead; anything else gets
58
+ the commands to run by hand)
59
+ - `ymmv version` prints the CLI version (and notes a newer release when one is known)
60
+
61
+ Values are capped at 256 characters and extra labels at 64; a profile holds up
62
+ to 32 extras.
63
+
64
+ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full contract
65
+ (shape, statuses, caching, CORS):
66
+ <https://github.com/ymmv-fyi/ymmv/blob/main/docs/api.md>.
67
+
68
+ ## Environment variables
69
+
70
+ - `NO_COLOR` disables color output (and `FORCE_COLOR=0`/`false` force-disables it).
71
+ - `YMMV_API` points the CLI at a different Worker (development/staging). Bare origin only.
72
+ - `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
73
+ the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
74
+ `ymmv login` / `ymmv logout` keep acting on the stored login. Viewing (`ymmv <handle>`) also
75
+ keeps using the stored login for the you-side of a diff. The token is sent to the server
76
+ `YMMV_API` selects, so set the two together.
77
+ - `YMMV_HANDLE` names the GitHub username `YMMV_TOKEN` belongs to. Required for `ymmv -y` and
78
+ `ymmv set`/`unset` under an env token (there is no server lookup for it); ignored without
79
+ `YMMV_TOKEN`.
80
+ - `YMMV_NO_UPDATE_CHECK` disables the startup check for newer releases (the ecosystem-standard
81
+ `NO_UPDATE_NOTIFIER` works too). The check is also off automatically under `CI`, in pipes,
82
+ and in dev builds; it never blocks or fails a command.
83
+
84
+ ## Publishing from CI
85
+
86
+ `ymmv login` needs a browser, so mint the token on your machine and hand it to CI:
87
+
88
+ 1. Run `ymmv login` locally.
89
+ 2. Copy the `token` value from the token file:
90
+ `~/.config/ymmv/token.json` (Linux), `~/Library/Preferences/ymmv/token.json` (macOS),
91
+ `%APPDATA%\ymmv\Config\token.json` (Windows).
92
+ 3. Set it as a CI secret named `YMMV_TOKEN`, and set `YMMV_HANDLE` to your GitHub username.
93
+ 4. Run `npx ymmv-cli@latest -y` in the job.
94
+
95
+ Two things to know:
96
+
97
+ - `ymmv -y` publishes the merge of your existing profile with what it detects on the machine it
98
+ runs on. Values you already published always win, but curated keys you have never set get the
99
+ CI runner's detected values (its OS, shell, and so on). For targeted updates from CI, prefer
100
+ `ymmv set <key> <value>`.
101
+ - A rejected or revoked `YMMV_TOKEN` fails with an error naming the variable; nothing falls back
102
+ to an interactive login, and the stored login file on the runner (if any) is left untouched.
103
+ `ymmv delete` acts on the account the token is bound to, regardless of `YMMV_HANDLE`.
55
104
 
56
105
  ## License
57
106
 
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,18 @@ 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
+ "update"
354
+ ];
338
355
  var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
339
356
  var RESERVED_SET = new Set(RESERVED);
340
357
  function isReserved(handle) {
@@ -382,7 +399,9 @@ function sanitizeValue(value) {
382
399
  }
383
400
  function useColor(env, isTTY) {
384
401
  if (env.NO_COLOR !== void 0) return false;
385
- if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
402
+ if (env.FORCE_COLOR !== void 0) {
403
+ return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
404
+ }
386
405
  if (env.TERM === "dumb") return false;
387
406
  return isTTY;
388
407
  }
@@ -399,10 +418,10 @@ function isHttpUrl(value) {
399
418
  return HTTP_URL_RE.test(value.trim());
400
419
  }
401
420
  var NO_OSC8_TERMS = /* @__PURE__ */ new Set(["linux", "dumb"]);
402
- function link(url, color, term = process.env.TERM) {
421
+ function link(url, color, term = process.env.TERM, label) {
403
422
  const clean = sanitizeValue(url).trim();
404
423
  if (!color) return clean;
405
- const text = `${CODES.amber}${displayUrl(clean)}${CODES.reset}`;
424
+ const text = `${CODES.amber}${label ? sanitizeValue(label) : displayUrl(clean)}${CODES.reset}`;
406
425
  if (term !== void 0 && NO_OSC8_TERMS.has(term)) return text;
407
426
  return `${OSC}8;;${clean}${ST}${text}${OSC}8;;${ST}`;
408
427
  }
@@ -442,13 +461,15 @@ function renderProfile(profile, opts) {
442
461
  const val = (v) => isHttpUrl(v) ? link(v, opts.color) : v;
443
462
  const lines = [
444
463
  "",
445
- ` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`,
446
- ""
464
+ ` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`
447
465
  ];
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
- );
466
+ if (rows.length) {
467
+ lines.push("");
468
+ for (const r of rows) {
469
+ lines.push(
470
+ r.value === null ? ` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}` : ` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}`
471
+ );
472
+ }
452
473
  }
453
474
  if (extras.length) {
454
475
  lines.push("");
@@ -519,7 +540,7 @@ function nudge(color) {
519
540
  function notFound(handle, color, base) {
520
541
  return `
521
542
  no ymmv profile for "${sanitizeValue(handle)}" yet.
522
- publish one at ${link(base, color)} with: npx ymmv-cli`;
543
+ publish one at ${link(base, color)} with: npx ymmv-cli@latest`;
523
544
  }
524
545
 
525
546
  // src/http.ts
@@ -553,17 +574,30 @@ function wireText(text) {
553
574
  return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
554
575
  }
555
576
  var INVISIBLE_RE = new RegExp("\\p{Default_Ignorable_Code_Point}", "gu");
556
- async function serverMessage(res) {
577
+ async function wireBody(res) {
578
+ try {
579
+ return await res.text();
580
+ } catch (err) {
581
+ if (isTimeoutError(err)) throw err;
582
+ return "";
583
+ }
584
+ }
585
+ function wireErrorBody(raw) {
557
586
  try {
558
- const body = await res.json();
587
+ const body = JSON.parse(raw);
588
+ const out = {};
589
+ if (typeof body?.error === "string") out.slug = body.error;
559
590
  if (typeof body?.message === "string") {
560
591
  const clean = wireText(body.message).trim();
561
- if (clean.replace(INVISIBLE_RE, "").trim()) return clean;
592
+ if (clean.replace(INVISIBLE_RE, "").trim()) out.message = clean;
562
593
  }
563
- } catch (err) {
564
- if (isTimeoutError(err)) throw err;
594
+ return out;
595
+ } catch {
596
+ return {};
565
597
  }
566
- return void 0;
598
+ }
599
+ async function serverMessage(res) {
600
+ return wireErrorBody(await wireBody(res)).message;
567
601
  }
568
602
  function withRetryHint(msg, res) {
569
603
  const retry = res.headers.get("retry-after");
@@ -574,6 +608,12 @@ function displayError(err) {
574
608
  const text = err instanceof Error ? err.message : String(err);
575
609
  return text.split(/\r?\n/).map((line) => sanitizeValue(line)).join("\n");
576
610
  }
611
+ var NetworkError = class extends Error {
612
+ constructor(message2, options) {
613
+ super(message2, options);
614
+ this.name = "NetworkError";
615
+ }
616
+ };
577
617
  async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
578
618
  try {
579
619
  return await fetchFn(url, {
@@ -581,17 +621,58 @@ async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
581
621
  signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
582
622
  });
583
623
  } catch (err) {
584
- throw new Error(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
624
+ throw new NetworkError(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
585
625
  cause: err
586
626
  });
587
627
  }
588
628
  }
589
629
 
590
- // src/index.ts
591
- import { readFileSync as readFileSync2 } from "fs";
592
-
593
630
  // src/config.ts
594
- var BASE = (process.env.YMMV_API ?? "https://ymmv.fyi").replace(/\/+$/, "");
631
+ function normalizeBase(raw) {
632
+ return raw.replace(/\/+$/, "");
633
+ }
634
+ var BASE = normalizeBase(process.env.YMMV_API || "https://ymmv.fyi");
635
+ function baseProblem(raw = process.env.YMMV_API) {
636
+ if (raw === void 0 || raw === "") return null;
637
+ const shown = `YMMV_API is set to "${sanitizeValue(raw)}"`;
638
+ if (/\s/.test(raw)) return `${shown} which contains whitespace. Remove it.`;
639
+ const base = normalizeBase(raw);
640
+ let url;
641
+ try {
642
+ url = new URL(base);
643
+ } catch {
644
+ return `${shown} which is not a full URL. Use a bare origin like https://ymmv.fyi (the http:// or https:// scheme is required).`;
645
+ }
646
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
647
+ return `${shown} which is not an http or https URL. Use a bare origin like https://ymmv.fyi.`;
648
+ }
649
+ if (url.username || url.password) {
650
+ return `${shown} which contains credentials. Use a bare origin like https://ymmv.fyi.`;
651
+ }
652
+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
653
+ 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.`;
654
+ }
655
+ if (base !== url.origin) {
656
+ return `${shown} which is not in canonical form. Use exactly the scheme and host, like https://ymmv.fyi.`;
657
+ }
658
+ return null;
659
+ }
660
+ function credentialEnvProblem(rawToken = process.env.YMMV_TOKEN, rawHandle = process.env.YMMV_HANDLE) {
661
+ if (rawToken === void 0 || rawToken === "") return null;
662
+ if (!/^[\x21-\x7E]+$/.test(rawToken)) {
663
+ return "YMMV_TOKEN contains whitespace, control, or non-ASCII characters. Set it to the exact token value.";
664
+ }
665
+ if (rawHandle !== void 0 && rawHandle !== "") {
666
+ const shown = `YMMV_HANDLE is set to "${sanitizeValue(rawHandle)}"`;
667
+ if (!isValidHandle(rawHandle)) {
668
+ return `${shown} which is not a valid GitHub username. Set it to the handle bound to YMMV_TOKEN.`;
669
+ }
670
+ if (isReserved(rawHandle)) {
671
+ return `${shown} which is a reserved word, so no handle can be bound to it.`;
672
+ }
673
+ }
674
+ return null;
675
+ }
595
676
 
596
677
  // src/auth-http.ts
597
678
  async function bodyJson(res) {
@@ -631,7 +712,16 @@ async function mintYmmvToken(accessToken) {
631
712
  );
632
713
  }
633
714
  const body = await bodyJson(res) ?? {};
634
- throw new Error(`login failed: ${res.status} ${wireText(body.error ?? "")}`.trim());
715
+ const slug = typeof body.error === "string" ? body.error : "";
716
+ if (res.status === 401 && slug === "github_auth_failed") {
717
+ throw new Error("GitHub rejected the authorization. Run `ymmv login` to try again.");
718
+ }
719
+ if (res.status === 500 && slug === "internal_error") {
720
+ throw new Error(
721
+ "The server hit an error minting your login. Run `ymmv login` again shortly."
722
+ );
723
+ }
724
+ throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
635
725
  }
636
726
  const data = await bodyJson(res);
637
727
  if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
@@ -690,38 +780,49 @@ async function saveToken(data) {
690
780
  throw e;
691
781
  }
692
782
  }
693
- async function loadToken() {
694
- let raw;
783
+ async function readTokenFile() {
695
784
  try {
696
- raw = await readFile(tokenFilePath(), "utf8");
785
+ const parsed = JSON.parse(await readFile(tokenFilePath(), "utf8"));
786
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
697
787
  } catch {
698
788
  return null;
699
789
  }
700
- let parsed;
701
- try {
702
- parsed = JSON.parse(raw);
703
- } catch {
790
+ }
791
+ async function loadToken() {
792
+ const parsed = await readTokenFile();
793
+ if (!parsed || parsed.base !== BASE || typeof parsed.token !== "string" || parsed.token === "" || parsed.handle !== null && typeof parsed.handle !== "string") {
704
794
  return null;
705
795
  }
706
- if (parsed.base !== BASE || typeof parsed.token !== "string") return null;
707
796
  return parsed;
708
797
  }
798
+ async function peekCredential() {
799
+ const parsed = await readTokenFile();
800
+ return parsed && typeof parsed.base === "string" && typeof parsed.token === "string" && parsed.token !== "" ? { base: parsed.base, token: parsed.token } : null;
801
+ }
802
+ async function loadCredential() {
803
+ const envToken = process.env.YMMV_TOKEN || "";
804
+ if (envToken !== "") {
805
+ return { base: BASE, token: envToken, handle: process.env.YMMV_HANDLE || null, source: "env" };
806
+ }
807
+ const stored = await loadToken();
808
+ return stored ? { ...stored, source: "file" } : null;
809
+ }
709
810
  async function deleteToken() {
710
811
  await rm(tokenFilePath(), { force: true });
711
812
  }
712
813
  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
- }
814
+ const parsed = await readTokenFile();
815
+ return typeof parsed?.base === "string" ? parsed.base : null;
719
816
  }
720
817
 
721
818
  // src/device-flow.ts
722
819
  var DEVICE_CODE_URL = "https://github.com/login/device/code";
723
820
  var TOKEN_URL = "https://github.com/login/oauth/access_token";
724
821
  var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
822
+ var DEFAULT_POLL_INTERVAL_S = 5;
823
+ function usableInterval(v) {
824
+ return typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 900;
825
+ }
725
826
  async function requestDeviceCode(deps = {}) {
726
827
  const doFetch = deps.fetch ?? globalThis.fetch;
727
828
  const res = await safeFetch(
@@ -741,7 +842,10 @@ async function requestDeviceCode(deps = {}) {
741
842
  if (isTimeoutError(err)) throw err;
742
843
  return null;
743
844
  });
744
- if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.expires_in !== "number") {
845
+ 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
846
+ // "expired"), Infinity/1e300 make it unreachable (a middlebox feeding parseable
847
+ // authorization_pending bodies would hold the login forever - the deadline is the only exit).
848
+ 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
849
  throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
746
850
  }
747
851
  return data;
@@ -750,7 +854,7 @@ async function pollForToken(dc, deps = {}) {
750
854
  const doFetch = deps.fetch ?? globalThis.fetch;
751
855
  const sleep = deps.sleep ?? realSleep;
752
856
  const now = deps.now ?? Date.now;
753
- let interval = dc.interval || 5;
857
+ let interval = usableInterval(dc.interval) ? dc.interval : DEFAULT_POLL_INTERVAL_S;
754
858
  const deadline = now() + dc.expires_in * 1e3;
755
859
  let transientFailures = 0;
756
860
  let lastCause = "";
@@ -802,7 +906,7 @@ async function pollForToken(dc, deps = {}) {
802
906
  case "authorization_pending":
803
907
  break;
804
908
  case "slow_down":
805
- interval = Math.max(tok.interval ?? 0, interval + 5);
909
+ interval = usableInterval(tok.interval) ? Math.max(tok.interval, interval + DEFAULT_POLL_INTERVAL_S) : interval + DEFAULT_POLL_INTERVAL_S;
806
910
  break;
807
911
  case "access_denied":
808
912
  throw new Error("Authorization denied. Run `ymmv login` to try again.");
@@ -820,9 +924,24 @@ async function login(deps = {}) {
820
924
  "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
925
  );
822
926
  }
823
- const dc = await requestDeviceCode(deps);
927
+ if (process.env.YMMV_TOKEN) {
928
+ console.error(
929
+ message(
930
+ "YMMV_TOKEN is set and takes precedence over stored logins. This login will be saved but not used until you unset it."
931
+ )
932
+ );
933
+ }
934
+ const prior = await peekCredential();
824
935
  const color = colorEnabled();
825
936
  const c = palette(color);
937
+ if (prior && prior.base !== BASE) {
938
+ console.error(
939
+ message(
940
+ `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\`.`
941
+ )
942
+ );
943
+ }
944
+ const dc = await requestDeviceCode(deps);
826
945
  const verifyUri = /^https:\/\/github\.com\//.test(dc.verification_uri) ? link(dc.verification_uri, color) : sanitizeValue(dc.verification_uri);
827
946
  console.log(
828
947
  message(
@@ -832,6 +951,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
832
951
  );
833
952
  const accessToken = await pollForToken(dc, deps);
834
953
  const { token, handle } = await mintYmmvToken(accessToken);
954
+ const replaced = await peekCredential();
835
955
  try {
836
956
  await saveToken({ token, handle });
837
957
  } catch (e) {
@@ -839,6 +959,13 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
839
959
  });
840
960
  throw e;
841
961
  }
962
+ if (replaced && replaced.base === BASE && replaced.token !== token) {
963
+ try {
964
+ await revokeYmmvToken(replaced.token);
965
+ } catch {
966
+ console.error(message(`${c.faint}(couldn't revoke the previous session's token)${c.reset}`));
967
+ }
968
+ }
842
969
  console.log(
843
970
  message(
844
971
  handle ? `Logged in as ${handle}.` : "Logged in. No handle bound (your GitHub username is a reserved word)."
@@ -847,14 +974,21 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
847
974
  }
848
975
 
849
976
  // src/api.ts
977
+ var PublishRefusal = class extends Error {
978
+ constructor(msg) {
979
+ super(msg);
980
+ this.name = "PublishRefusal";
981
+ }
982
+ };
983
+ var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (revoked or expired).";
850
984
  async function rateLimitMessage(res) {
851
985
  return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
852
986
  }
853
987
  async function ensureLogin() {
854
- const existing = await loadToken();
988
+ const existing = await loadCredential();
855
989
  if (existing) return existing;
856
990
  await login();
857
- const fresh = await loadToken();
991
+ const fresh = await loadCredential();
858
992
  if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
859
993
  return fresh;
860
994
  }
@@ -874,35 +1008,60 @@ async function publishProfile(profile) {
874
1008
  );
875
1009
  let cred = await ensureLogin();
876
1010
  if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
877
- throw new Error(
1011
+ throw new PublishRefusal(
878
1012
  "The stored login changed while this command was running. Re-run it under the current account."
879
1013
  );
880
1014
  }
881
1015
  let res = await send(cred);
882
1016
  if (res.status === 401 || res.status === 409) {
1017
+ if (cred.source === "env") {
1018
+ throw new PublishRefusal(
1019
+ 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`."
1020
+ );
1021
+ }
883
1022
  const was401 = res.status === 401;
1023
+ console.log(
1024
+ message(
1025
+ was401 ? "Session expired. Logging in again to retry the publish." : "The server no longer recognizes your handle. Logging in again to retry the publish."
1026
+ )
1027
+ );
884
1028
  if (was401) await deleteToken();
885
1029
  await login();
886
1030
  cred = await ensureLogin();
887
1031
  if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
888
1032
  const bound = sanitizeValue(cred.handle ?? "");
889
- throw new Error(
1033
+ throw new PublishRefusal(
890
1034
  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
1035
  );
892
1036
  }
893
1037
  res = await send(cred);
894
- if (res.status === 401) throw new Error("Authentication failed. Run `ymmv login`.");
1038
+ if (res.status === 401) throw new PublishRefusal("Authentication failed. Run `ymmv login`.");
895
1039
  if (res.status === 409) {
1040
+ const { slug, message: srvMsg } = wireErrorBody(await wireBody(res));
1041
+ if (slug === "handle_not_bound") {
1042
+ throw new PublishRefusal(
1043
+ "The server still refuses this handle after a fresh login. Wait a moment and re-run the command."
1044
+ );
1045
+ }
896
1046
  throw new Error(
897
- await serverMessage(res) ?? "that handle is taken by another account (your GitHub handle may have been reused)."
1047
+ srvMsg ?? "that handle is taken by another account (your GitHub handle may have been reused)."
898
1048
  );
899
1049
  }
900
1050
  }
901
1051
  if (res.status === 429) throw new Error(await rateLimitMessage(res));
902
1052
  if (!res.ok) {
903
- throw new Error(`publish failed: ${res.status} ${wireText(await res.text())}`);
1053
+ const raw = await wireBody(res);
1054
+ const parsed = wireErrorBody(raw);
1055
+ if (res.status === 400 && parsed.slug === "unsupported_schema_version") {
1056
+ throw new PublishRefusal(parsed.message ?? `publish failed: ${res.status} ${wireText(raw)}`);
1057
+ }
1058
+ throw new Error(parsed.message ?? `publish failed: ${res.status} ${wireText(raw)}`);
1059
+ }
1060
+ let data = {};
1061
+ try {
1062
+ data = await res.json();
1063
+ } catch {
904
1064
  }
905
- const data = await res.json();
906
1065
  const shown = typeof data.handle === "string" ? sanitizeValue(data.handle) : profile.handle;
907
1066
  return { handle: shown, url: `${BASE}/${shown}` };
908
1067
  }
@@ -914,8 +1073,7 @@ async function fetchProfileJson(handle) {
914
1073
  }
915
1074
  return parseProfile(await res.json());
916
1075
  }
917
- async function deleteProfile() {
918
- const cred = await ensureLogin();
1076
+ async function deleteProfile(cred) {
919
1077
  const res = await safeFetch(
920
1078
  `${BASE}/api/v1/profile`,
921
1079
  {
@@ -926,11 +1084,14 @@ async function deleteProfile() {
926
1084
  BASE
927
1085
  );
928
1086
  if (res.status === 401) {
929
- throw new Error("Session expired. Run `ymmv login`, then `ymmv delete` again.");
1087
+ throw new Error(
1088
+ cred.source === "env" ? `${ENV_TOKEN_REJECTED} Update YMMV_TOKEN and run \`ymmv delete\` again.` : "Session expired. Run `ymmv login`, then `ymmv delete` again."
1089
+ );
930
1090
  }
931
1091
  if (res.status === 429) throw new Error(await rateLimitMessage(res));
932
1092
  if (!res.ok) {
933
- throw new Error(`delete failed: ${res.status} ${wireText(await res.text())}`);
1093
+ const raw = await wireBody(res);
1094
+ throw new Error(wireErrorBody(raw).message ?? `delete failed: ${res.status} ${wireText(raw)}`);
934
1095
  }
935
1096
  }
936
1097
 
@@ -1299,7 +1460,7 @@ function requireHandle(cred) {
1299
1460
  if (cred.handle) return cred.handle;
1300
1461
  console.error(
1301
1462
  message(
1302
- "Your GitHub username is a reserved word, so no handle is bound. Rename on GitHub, then run `ymmv login` again."
1463
+ 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
1464
  )
1304
1465
  );
1305
1466
  process.exitCode = 1;
@@ -1334,9 +1495,21 @@ async function promptEntries(defaults, prompter) {
1334
1495
  console.log(message(`${c.faint}Enter to keep, "-" to clear${c.reset}`));
1335
1496
  const chosen = /* @__PURE__ */ new Map();
1336
1497
  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);
1498
+ for (; ; ) {
1499
+ const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
1500
+ const value = answer === "-" ? "" : answer;
1501
+ if (value.length > MAX_VALUE) {
1502
+ const isDefault = value === (defaults.get(key) ?? "").trim();
1503
+ console.log(
1504
+ message(
1505
+ `${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}`
1506
+ )
1507
+ );
1508
+ continue;
1509
+ }
1510
+ if (value) chosen.set(key, value);
1511
+ break;
1512
+ }
1340
1513
  }
1341
1514
  return chosen;
1342
1515
  }
@@ -1391,6 +1564,16 @@ async function publish(io) {
1391
1564
  let values = defaults;
1392
1565
  const assemble = () => [...entriesFromMap(values), ...carried];
1393
1566
  if (!io.interactive || !io.prompter || io.yes) {
1567
+ const over = [...values].find(([, v]) => v.length > MAX_VALUE);
1568
+ if (over) {
1569
+ console.error(
1570
+ message(
1571
+ `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>.`
1572
+ )
1573
+ );
1574
+ process.exitCode = 1;
1575
+ return;
1576
+ }
1394
1577
  const entries = assemble();
1395
1578
  showCard(entries);
1396
1579
  printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
@@ -1408,8 +1591,20 @@ async function publish(io) {
1408
1591
  "Y/n/e=edit"
1409
1592
  );
1410
1593
  if (ans === "y") {
1411
- printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1412
- return;
1594
+ try {
1595
+ printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1596
+ return;
1597
+ } catch (e) {
1598
+ if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
1599
+ const ambiguous = e instanceof NetworkError || isTimeoutError(e);
1600
+ console.error(
1601
+ message(
1602
+ `${displayError(e)}
1603
+ ${ambiguous ? "The publish may not have completed. Your answers are kept." : "Nothing was published. Your answers are kept."}`
1604
+ )
1605
+ );
1606
+ continue;
1607
+ }
1413
1608
  }
1414
1609
  if (ans === "n") {
1415
1610
  console.log(message("Aborted. Nothing published."));
@@ -1436,7 +1631,11 @@ async function view(handle) {
1436
1631
  }
1437
1632
  const cred = await loadToken();
1438
1633
  if (cred?.handle) {
1439
- const mine = await fetchProfileJson(cred.handle).catch(() => null);
1634
+ let mineFailed = false;
1635
+ const mine = await fetchProfileJson(cred.handle).catch(() => {
1636
+ mineFailed = true;
1637
+ return null;
1638
+ });
1440
1639
  if (mine && mine.handle.toLowerCase() !== theirs.handle.toLowerCase()) {
1441
1640
  console.log(
1442
1641
  renderDiff(diff(mine, theirs), { color: c, theirsLabel: theirs.handle, mineLabel: "you" })
@@ -1445,7 +1644,12 @@ async function view(handle) {
1445
1644
  }
1446
1645
  if (!mine) {
1447
1646
  console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
1448
- console.log(nudge(c));
1647
+ if (mineFailed) {
1648
+ const codes = palette(c);
1649
+ console.error(message(`${codes.faint}(couldn't load your profile to diff)${codes.reset}`));
1650
+ } else {
1651
+ console.log(nudge(c));
1652
+ }
1449
1653
  return;
1450
1654
  }
1451
1655
  }
@@ -1458,6 +1662,15 @@ async function runSet(target) {
1458
1662
  const existing = await fetchProfileJson(handle);
1459
1663
  assertHandleUnchanged(existing, handle);
1460
1664
  const { entries, extras } = applySet(existing, target);
1665
+ if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
1666
+ console.error(
1667
+ message(
1668
+ `Your profile already has ${MAX_EXTRAS} extras; that's the cap. Remove one first: ymmv unset --extra "Label".`
1669
+ )
1670
+ );
1671
+ process.exitCode = 1;
1672
+ return;
1673
+ }
1461
1674
  const res = await publishProfile(newProfile(handle, entries, extras));
1462
1675
  const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
1463
1676
  console.log(message(`${line}${pagePointer(res.handle)}`));
@@ -1487,7 +1700,7 @@ async function runUnset(target) {
1487
1700
  }
1488
1701
  async function runDelete(io) {
1489
1702
  const cred = await ensureLogin();
1490
- const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
1703
+ const target = cred.source === "env" ? "the profile bound to YMMV_TOKEN" : cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
1491
1704
  if (!io.yes) {
1492
1705
  if (!io.interactive || !io.prompter) {
1493
1706
  console.error(
@@ -1515,8 +1728,8 @@ ${message("Cancelled. Nothing deleted.")}`);
1515
1728
  return;
1516
1729
  }
1517
1730
  }
1518
- await deleteProfile();
1519
- await deleteToken();
1731
+ await deleteProfile(cred);
1732
+ if (cred.source === "file") await deleteToken();
1520
1733
  console.log(message(`Deleted ${target}. Run \`ymmv\` to publish again.`));
1521
1734
  }
1522
1735
 
@@ -1526,15 +1739,33 @@ var UNSET_EXTRA = 'ymmv unset --extra "Label"';
1526
1739
  var SET_USAGE = `usage: ymmv set <key> <value> | ${SET_EXTRA}`;
1527
1740
  var EXTRA_USAGE = `usage: ${SET_EXTRA}`;
1528
1741
  var UNSET_USAGE = `usage: ymmv unset <key> | ${UNSET_EXTRA}`;
1742
+ var VIEW_USAGE = "usage: ymmv view <handle>";
1529
1743
  function invalidKeyError(head, hint) {
1530
1744
  return {
1531
1745
  kind: "error",
1532
- message: `"${head}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
1746
+ message: `"${sanitizeValue(head)}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
1533
1747
  For anything else, use: ${hint}.`
1534
1748
  };
1535
1749
  }
1536
- function hasYes(args) {
1537
- return args.includes("-y") || args.includes("--yes");
1750
+ function noArgs(verb, rest) {
1751
+ return rest.length === 0 ? { kind: verb } : { kind: "error", message: `usage: ymmv ${verb}` };
1752
+ }
1753
+ function yesOnly(usage, rest, make) {
1754
+ if (rest.length === 0) return make(false);
1755
+ if (rest.length === 1 && (rest[0] === "-y" || rest[0] === "--yes")) return make(true);
1756
+ return { kind: "error", message: usage };
1757
+ }
1758
+ function labelCapError(label) {
1759
+ return {
1760
+ kind: "error",
1761
+ message: `That label is ${label.length} characters; the cap is ${MAX_LABEL}.`
1762
+ };
1763
+ }
1764
+ function valueCapError(value) {
1765
+ return {
1766
+ kind: "error",
1767
+ message: `That value is ${value.length} characters; the cap is ${MAX_VALUE}.`
1768
+ };
1538
1769
  }
1539
1770
  function parseSet(rest) {
1540
1771
  const head = rest[0];
@@ -1546,6 +1777,8 @@ function parseSet(rest) {
1546
1777
  const value2 = spec.slice(eq + 1).trim();
1547
1778
  if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
1548
1779
  if (value2 === "-") return { kind: "unset", target: { kind: "extra", label } };
1780
+ if (label.length > MAX_LABEL) return labelCapError(label);
1781
+ if (value2.length > MAX_VALUE) return valueCapError(value2);
1549
1782
  return { kind: "set", target: { kind: "extra", label, value: value2 } };
1550
1783
  }
1551
1784
  if (!head) return { kind: "error", message: SET_USAGE };
@@ -1553,6 +1786,7 @@ function parseSet(rest) {
1553
1786
  const value = rest.slice(1).join(" ").trim();
1554
1787
  if (!value) return { kind: "error", message: `usage: ymmv set ${head} <value>` };
1555
1788
  if (value === "-") return { kind: "unset", target: { kind: "curated", key: head } };
1789
+ if (value.length > MAX_VALUE) return valueCapError(value);
1556
1790
  return { kind: "set", target: { kind: "curated", key: head, value } };
1557
1791
  }
1558
1792
  function parseUnset(rest) {
@@ -1575,43 +1809,340 @@ function parseUnset(rest) {
1575
1809
  }
1576
1810
  function resolveArg(argv) {
1577
1811
  const first = argv[0];
1812
+ const rest = argv.slice(1);
1578
1813
  if (first === "-h" || first === "--help" || first === "help") return { kind: "help" };
1579
- if (first === "-V" || first === "-v" || first === "--version") return { kind: "version" };
1814
+ if (first === "-V" || first === "-v" || first === "--version" || first === "version") {
1815
+ return rest.length === 0 ? { kind: "version" } : { kind: "error", message: "usage: ymmv version" };
1816
+ }
1580
1817
  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));
1818
+ if (first === "-y" || first === "--yes") {
1819
+ if (rest.length > 0) {
1820
+ const example = rest[0] === "delete" || rest[0] === "publish" ? rest[0] : "publish";
1821
+ return {
1822
+ kind: "error",
1823
+ message: `Put ${first} after the command: ymmv ${example} -y. A bare ymmv -y publishes without prompts.`
1824
+ };
1825
+ }
1826
+ return { kind: "publish", yes: true };
1827
+ }
1828
+ if (first === "login" || first === "logout" || first === "update") return noArgs(first, rest);
1829
+ if (first === "publish") {
1830
+ return yesOnly("usage: ymmv publish [-y]", rest, (yes) => ({ kind: "publish", yes }));
1831
+ }
1832
+ if (first === "delete") {
1833
+ return yesOnly(
1834
+ "usage: ymmv delete [-y] (deletes your own profile; takes no handle)",
1835
+ rest,
1836
+ (yes) => ({ kind: "delete", yes })
1837
+ );
1838
+ }
1839
+ if (first === "set") return parseSet(rest);
1840
+ if (first === "unset") return parseUnset(rest);
1587
1841
  if (first === "view") {
1588
- const handle = argv[1];
1589
- if (!handle) return { kind: "error", message: "usage: ymmv view <handle>" };
1842
+ const handle = rest[0];
1843
+ if (!handle) return { kind: "error", message: VIEW_USAGE };
1590
1844
  if (!isValidHandle(handle)) {
1591
- return { kind: "error", message: `"${handle}" is not a valid GitHub handle.` };
1845
+ return { kind: "error", message: `"${sanitizeValue(handle)}" is not a valid GitHub handle.` };
1592
1846
  }
1593
1847
  if (isReserved(handle)) return reservedError(handle);
1848
+ if (rest.length > 1) return { kind: "error", message: VIEW_USAGE };
1594
1849
  return { kind: "view", handle };
1595
1850
  }
1596
1851
  if (first.startsWith("-")) {
1597
- return { kind: "error", message: `Unknown option "${first}". Run \`ymmv help\`.` };
1852
+ return {
1853
+ kind: "error",
1854
+ message: `Unknown option "${sanitizeValue(first)}". Run \`ymmv help\`.`
1855
+ };
1598
1856
  }
1599
1857
  if (!isValidHandle(first)) {
1600
1858
  return {
1601
1859
  kind: "error",
1602
- message: `"${first}" is not a valid GitHub handle. Run \`ymmv help\`.`
1860
+ message: `"${sanitizeValue(first)}" is not a valid GitHub handle. Run \`ymmv help\`.`
1603
1861
  };
1604
1862
  }
1605
- if (isReserved(first)) return reservedError(first);
1863
+ if (isReserved(first)) return reservedError(first, true);
1864
+ if (rest.length > 0) {
1865
+ return { kind: "error", message: `Unexpected arguments after "${first}". Run \`ymmv help\`.` };
1866
+ }
1606
1867
  return { kind: "view", handle: first };
1607
1868
  }
1608
- function reservedError(handle) {
1869
+ function reservedError(handle, hintVerbs = false) {
1870
+ const verb = handle.toLowerCase();
1871
+ const hint = hintVerbs && CLI_VERBS.includes(verb) ? ` Did you mean: ymmv ${verb}?` : "";
1609
1872
  return {
1610
1873
  kind: "error",
1611
- message: `"${handle}" is a reserved name; it can't have a profile.`
1874
+ message: `"${handle}" is a reserved name; it can't have a profile.${hint}`
1612
1875
  };
1613
1876
  }
1614
1877
 
1878
+ // src/update.ts
1879
+ import { spawn as nodeSpawn } from "child_process";
1880
+ import { homedir } from "os";
1881
+
1882
+ // src/update-check.ts
1883
+ import { randomUUID as randomUUID2 } from "crypto";
1884
+ import { readFileSync as readFileSync2, realpathSync } from "fs";
1885
+ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1886
+ import { dirname as dirname2, join as join2 } from "path";
1887
+ import envPaths2 from "env-paths";
1888
+ var REGISTRY_URL = "https://registry.npmjs.org/ymmv-cli/latest";
1889
+ var RELEASES_URL = "https://github.com/ymmv-fyi/ymmv/releases";
1890
+ var FETCH_TIMEOUT_MS = 2e3;
1891
+ var FINISH_GRACE_MS = 500;
1892
+ var SUCCESS_TTL_MS = 24 * 60 * 60 * 1e3;
1893
+ var FAILURE_TTL_MS = 6 * 60 * 60 * 1e3;
1894
+ var VERSION_FRESH_MS = 7 * 24 * 60 * 60 * 1e3;
1895
+ function ownVersion() {
1896
+ try {
1897
+ const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
1898
+ return typeof pkg.version === "string" ? pkg.version : null;
1899
+ } catch {
1900
+ return null;
1901
+ }
1902
+ }
1903
+ function parseTriple(v) {
1904
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v);
1905
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
1906
+ }
1907
+ function isNewer(latest, current) {
1908
+ const l = parseTriple(latest);
1909
+ const c = parseTriple(current);
1910
+ if (!l || !c) return false;
1911
+ for (let i = 0; i < 3; i++) {
1912
+ if (l[i] !== c[i]) return l[i] > c[i];
1913
+ }
1914
+ return false;
1915
+ }
1916
+ function updateCachePath() {
1917
+ return join2(envPaths2("ymmv", { suffix: "" }).config, "update-check.json");
1918
+ }
1919
+ async function readCache(path, now) {
1920
+ try {
1921
+ const parsed = JSON.parse(await readFile2(path, "utf8"));
1922
+ if (typeof parsed !== "object" || parsed === null) return null;
1923
+ const c = parsed;
1924
+ if (typeof c.lastCheckedAt !== "number" || typeof c.ok !== "boolean") return null;
1925
+ if (!Number.isFinite(c.lastCheckedAt) || c.lastCheckedAt > now) return null;
1926
+ if (c.latest !== void 0 && typeof c.latest !== "string") return null;
1927
+ if (c.latestAt !== void 0 && (!Number.isFinite(c.latestAt) || c.latestAt > now)) return null;
1928
+ return { lastCheckedAt: c.lastCheckedAt, ok: c.ok, latest: c.latest, latestAt: c.latestAt };
1929
+ } catch {
1930
+ return null;
1931
+ }
1932
+ }
1933
+ async function writeCache(path, cache) {
1934
+ const tmp = `${path}.${randomUUID2()}.tmp`;
1935
+ try {
1936
+ await mkdir2(dirname2(path), { recursive: true });
1937
+ await writeFile2(tmp, JSON.stringify(cache));
1938
+ await rename2(tmp, path);
1939
+ } catch {
1940
+ await rm2(tmp, { force: true }).catch(() => {
1941
+ });
1942
+ }
1943
+ }
1944
+ function binRealpath() {
1945
+ const raw = process.argv[1];
1946
+ if (!raw) return void 0;
1947
+ try {
1948
+ return realpathSync(raw);
1949
+ } catch {
1950
+ return raw;
1951
+ }
1952
+ }
1953
+ var NPM_PREFIX_ROOTS = /* @__PURE__ */ new Set(["usr", "local", "homebrew", "nodejs", "node", "installation"]);
1954
+ var VERSIONISH_RE = /^v?\d+(\.\d+)*$/;
1955
+ function detectInstallMethod(binPath) {
1956
+ if (!binPath) return "unknown";
1957
+ const segs = binPath.split(/[\\/]+/).filter(Boolean).map((s) => s.toLowerCase());
1958
+ const has = (name) => segs.includes(name);
1959
+ if (has("_npx")) return "ephemeral";
1960
+ if (has("dlx") || segs.some((s) => s.startsWith("dlx-"))) return "ephemeral";
1961
+ if (segs.some((s) => s.startsWith("bunx-"))) return "ephemeral";
1962
+ if (has(".bun") && has("cache")) return "ephemeral";
1963
+ if (has(".bun") && (has("global") || has("bin"))) return "bun-global";
1964
+ if (has(".volta")) return "unknown";
1965
+ if (segs.some((s, i) => s === "pnpm" && segs[i + 1] === "global")) return "pnpm-global";
1966
+ if (has(".pnpm")) return "unknown";
1967
+ const npmGlobal = segs.some(
1968
+ (s, i) => s === "node_modules" && segs[i + 1] === "ymmv-cli" && (segs[i - 1] === "lib" && i >= 2 && (NPM_PREFIX_ROOTS.has(segs[i - 2]) || VERSIONISH_RE.test(segs[i - 2])) || segs[i - 1] === "npm")
1969
+ );
1970
+ if (npmGlobal) return "npm-global";
1971
+ return "unknown";
1972
+ }
1973
+ function npxInvocation(latest) {
1974
+ const triple = latest ? parseTriple(latest) : null;
1975
+ return `npx ymmv-cli@${triple ? triple.join(".") : "latest"}`;
1976
+ }
1977
+ function formatUpdateNotice(current, latest, method, color, term) {
1978
+ const cur = parseTriple(current);
1979
+ const lat = parseTriple(latest);
1980
+ if (!cur || !lat || !isNewer(lat.join("."), cur.join("."))) return null;
1981
+ const latestClean = lat.join(".");
1982
+ const currentClean = cur.join(".");
1983
+ const cmd = method === "ephemeral" ? npxInvocation(latestClean) : "ymmv update";
1984
+ const c = palette(color);
1985
+ const headline = `ymmv-cli ${latestClean} is out`;
1986
+ const linked = color ? link(RELEASES_URL, true, term, headline) : headline;
1987
+ return `${linked} ${c.faint}(you have ${currentClean})${c.reset} \u2192 run ${c.bold}${cmd}${c.reset}`;
1988
+ }
1989
+ function updatesOptedOut(env) {
1990
+ if (env.YMMV_NO_UPDATE_CHECK) return true;
1991
+ if (env.NO_UPDATE_NOTIFIER) return true;
1992
+ if (env.CI && env.CI !== "false") return true;
1993
+ return false;
1994
+ }
1995
+ async function readCachedLatest(deps = {}) {
1996
+ const now = (deps.now ?? Date.now)();
1997
+ const cache = await readCache(deps.cachePath ?? updateCachePath(), now);
1998
+ if (!cache?.latest || cache.latestAt === void 0) return null;
1999
+ if (now - cache.latestAt > VERSION_FRESH_MS) return null;
2000
+ const triple = parseTriple(cache.latest);
2001
+ return triple ? triple.join(".") : null;
2002
+ }
2003
+ var GRACE = /* @__PURE__ */ Symbol("grace");
2004
+ function startUpdateCheck(deps = {}) {
2005
+ const env = deps.env ?? process.env;
2006
+ const nowFn = deps.now ?? Date.now;
2007
+ const stderrIsTTY = deps.stderrIsTTY ?? Boolean(process.stderr.isTTY);
2008
+ const inert = { finish: async () => null, abort: () => {
2009
+ } };
2010
+ if (updatesOptedOut(env)) return inert;
2011
+ if (!stderrIsTTY) return inert;
2012
+ const current = deps.currentVersion !== void 0 ? deps.currentVersion : ownVersion();
2013
+ const currentTriple = current === null ? null : parseTriple(current);
2014
+ if (!currentTriple || currentTriple.join(".") === "0.0.0") return inert;
2015
+ const currentClean = currentTriple.join(".");
2016
+ const cachePath = deps.cachePath ?? updateCachePath();
2017
+ const doFetch = deps.fetch ?? globalThis.fetch;
2018
+ const method = detectInstallMethod(deps.binPath ?? binRealpath());
2019
+ const color = useColor(env, stderrIsTTY);
2020
+ const term = env.TERM;
2021
+ const controller = new AbortController();
2022
+ const notice = (latest) => latest ? formatUpdateNotice(currentClean, latest, method, color, term) : null;
2023
+ const check = (async () => {
2024
+ const cache = await readCache(cachePath, nowFn());
2025
+ if (cache && nowFn() - cache.lastCheckedAt < (cache.ok ? SUCCESS_TTL_MS : FAILURE_TTL_MS)) {
2026
+ return notice(cache.latest);
2027
+ }
2028
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
2029
+ timeout.unref?.();
2030
+ let fetched = null;
2031
+ try {
2032
+ const res = await doFetch(REGISTRY_URL, {
2033
+ signal: controller.signal,
2034
+ headers: { accept: "application/json" }
2035
+ });
2036
+ if (res.ok) {
2037
+ const body = await res.json();
2038
+ if (typeof body.version === "string") fetched = body.version;
2039
+ }
2040
+ } catch {
2041
+ } finally {
2042
+ clearTimeout(timeout);
2043
+ }
2044
+ const triple = fetched === null ? null : parseTriple(fetched);
2045
+ const canonical2 = triple ? triple.join(".") : void 0;
2046
+ const now = nowFn();
2047
+ await writeCache(cachePath, {
2048
+ lastCheckedAt: now,
2049
+ ok: canonical2 !== void 0,
2050
+ latest: canonical2 ?? cache?.latest,
2051
+ latestAt: canonical2 !== void 0 ? now : cache?.latestAt
2052
+ });
2053
+ return notice(canonical2 ?? cache?.latest);
2054
+ })().catch(() => null);
2055
+ return {
2056
+ async finish() {
2057
+ const result = await new Promise((resolve) => {
2058
+ const timer = setTimeout(() => resolve(GRACE), FINISH_GRACE_MS);
2059
+ timer.unref?.();
2060
+ void check.then((v) => {
2061
+ clearTimeout(timer);
2062
+ resolve(v);
2063
+ });
2064
+ });
2065
+ if (result === GRACE) {
2066
+ controller.abort();
2067
+ return null;
2068
+ }
2069
+ return result;
2070
+ },
2071
+ abort() {
2072
+ controller.abort();
2073
+ }
2074
+ };
2075
+ }
2076
+
2077
+ // src/update.ts
2078
+ var UPGRADE_COMMANDS = {
2079
+ "npm-global": "npm i -g ymmv-cli@latest",
2080
+ "pnpm-global": "pnpm add -g ymmv-cli@latest",
2081
+ "bun-global": "bun add -g ymmv-cli@latest"
2082
+ };
2083
+ var MANUAL_LIST = Object.values(UPGRADE_COMMANDS).map((cmd) => ` ${cmd}`).join("\n");
2084
+ var NOT_FOUND_CODES = /* @__PURE__ */ new Set([127, 9009]);
2085
+ function manualFallback(intro) {
2086
+ return `${intro} Update with the command that matches your setup:
2087
+ ${MANUAL_LIST}`;
2088
+ }
2089
+ async function runUpdate(deps = {}) {
2090
+ const method = detectInstallMethod(deps.binPath ?? binRealpath());
2091
+ const c = palette(colorEnabled());
2092
+ if (method === "ephemeral") {
2093
+ const cached = await readCachedLatest({ now: deps.now, cachePath: deps.cachePath });
2094
+ const current = deps.currentVersion !== void 0 ? deps.currentVersion : ownVersion();
2095
+ const pin = cached !== null && current !== null && isNewer(cached, current) ? cached : null;
2096
+ console.log(
2097
+ message(
2098
+ `This run came from a package runner cache (npx-style), so there is no install to update.
2099
+ Next time run: ${c.bold}${npxInvocation(pin)}${c.reset}`
2100
+ )
2101
+ );
2102
+ return;
2103
+ }
2104
+ if (method === "unknown") {
2105
+ console.log(message(manualFallback("Couldn't tell how ymmv-cli was installed.")));
2106
+ return;
2107
+ }
2108
+ const cmd = UPGRADE_COMMANDS[method];
2109
+ const doSpawn = deps.spawn ?? nodeSpawn;
2110
+ const failSpawn = () => {
2111
+ console.error(message(manualFallback(`Couldn't run \`${cmd}\`.`)));
2112
+ process.exitCode = 1;
2113
+ };
2114
+ console.log(message(`${c.faint}running${c.reset} ${c.bold}${cmd}${c.reset}`));
2115
+ await new Promise((resolve) => {
2116
+ let child;
2117
+ try {
2118
+ child = doSpawn(cmd, { stdio: "inherit", shell: true, cwd: homedir() });
2119
+ } catch {
2120
+ failSpawn();
2121
+ resolve();
2122
+ return;
2123
+ }
2124
+ let settled = false;
2125
+ const settle = (fn) => {
2126
+ if (settled) return;
2127
+ settled = true;
2128
+ fn();
2129
+ resolve();
2130
+ };
2131
+ child.on("error", () => {
2132
+ settle(failSpawn);
2133
+ });
2134
+ child.on("exit", (code) => {
2135
+ settle(() => {
2136
+ if (code !== null && NOT_FOUND_CODES.has(code)) {
2137
+ failSpawn();
2138
+ } else if (code) {
2139
+ process.exitCode = code;
2140
+ }
2141
+ });
2142
+ });
2143
+ });
2144
+ }
2145
+
1615
2146
  // src/index.ts
1616
2147
  var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
1617
2148
 
@@ -1621,12 +2152,13 @@ ${c.faint}Usage:${c.reset}
1621
2152
  ymmv <handle> view a profile; logged in, see the diff vs yours
1622
2153
  ymmv view <handle> explicit view (same as ymmv <handle>)
1623
2154
  ymmv set <key> <value> set one curated key
1624
- ymmv set --extra "L=V" set a free-form extra
2155
+ ymmv set --extra "L=V" set a free-form extra (-e works too)
1625
2156
  ymmv unset <key> remove one curated key (ymmv set <key> - works too)
1626
2157
  ymmv unset --extra "L" remove a free-form extra
1627
- ymmv delete delete your profile (permanent)
2158
+ ymmv delete [-y] delete your profile (permanent; -y skips the confirm)
1628
2159
  ymmv login | logout GitHub device-flow auth
1629
- ymmv help | --version
2160
+ ymmv update update ymmv-cli to the latest release
2161
+ ymmv help | version
1630
2162
 
1631
2163
  ${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser, window-manager,
1632
2164
  font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
@@ -1636,7 +2168,7 @@ async function logout() {
1636
2168
  const otherBase = await peekBase();
1637
2169
  console.log(
1638
2170
  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."
2171
+ 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
2172
  )
1641
2173
  );
1642
2174
  return;
@@ -1644,10 +2176,10 @@ async function logout() {
1644
2176
  let revoked;
1645
2177
  try {
1646
2178
  revoked = await revokeYmmvToken(stored.token);
1647
- } catch {
2179
+ } catch (e) {
1648
2180
  console.error(
1649
2181
  message(
1650
- "Couldn't reach the server to revoke. Your token is still active. Run `ymmv logout` again when connected."
2182
+ 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
2183
  )
1652
2184
  );
1653
2185
  process.exitCode = 1;
@@ -1657,12 +2189,21 @@ async function logout() {
1657
2189
  console.log(message(revoked ? "Logged out." : "Logged out (no active session on this server)."));
1658
2190
  }
1659
2191
  function printVersion() {
1660
- try {
1661
- const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
1662
- console.log(`ymmv-cli ${pkg.version ?? "unknown"}`);
1663
- } catch {
1664
- console.log("ymmv-cli (version unknown)");
1665
- }
2192
+ const version = ownVersion();
2193
+ console.log(version === null ? "ymmv-cli (version unknown)" : `ymmv-cli ${version}`);
2194
+ }
2195
+ async function printLatestHint() {
2196
+ if (!process.stderr.isTTY) return;
2197
+ if (updatesOptedOut(process.env)) return;
2198
+ const current = ownVersion();
2199
+ const currentTriple = current === null ? null : parseTriple(current);
2200
+ if (!currentTriple || currentTriple.join(".") === "0.0.0") return;
2201
+ const latest = await readCachedLatest();
2202
+ if (latest === null || !isNewer(latest, currentTriple.join("."))) return;
2203
+ const c = palette(useColor(process.env, true));
2204
+ console.error(
2205
+ message(`${c.faint}latest: ${latest}${c.reset} \u2192 run ${c.bold}ymmv update${c.reset}`)
2206
+ );
1666
2207
  }
1667
2208
  async function interactive(run, yes) {
1668
2209
  const isTTY = Boolean(process.stdin.isTTY);
@@ -1673,8 +2214,30 @@ async function interactive(run, yes) {
1673
2214
  prompter?.close();
1674
2215
  }
1675
2216
  }
2217
+ function wantsUpdateCheck(kind) {
2218
+ return kind !== "help" && kind !== "version" && kind !== "error" && kind !== "update";
2219
+ }
1676
2220
  async function main(argv) {
1677
2221
  const cmd = resolveArg(argv);
2222
+ if (cmd.kind !== "logout" && cmd.kind !== "update") {
2223
+ const problem = baseProblem() ?? credentialEnvProblem();
2224
+ if (problem) {
2225
+ console.error(message(problem));
2226
+ process.exitCode = 1;
2227
+ return;
2228
+ }
2229
+ }
2230
+ const update = wantsUpdateCheck(cmd.kind) ? startUpdateCheck() : null;
2231
+ try {
2232
+ await dispatch(cmd);
2233
+ } catch (e) {
2234
+ update?.abort();
2235
+ throw e;
2236
+ }
2237
+ const notice = await update?.finish();
2238
+ if (notice) console.error(message(notice));
2239
+ }
2240
+ async function dispatch(cmd) {
1678
2241
  switch (cmd.kind) {
1679
2242
  case "publish":
1680
2243
  await interactive(publish, cmd.yes);
@@ -1699,12 +2262,23 @@ async function main(argv) {
1699
2262
  }
1700
2263
  case "logout":
1701
2264
  await logout();
2265
+ if (process.env.YMMV_TOKEN) {
2266
+ console.error(
2267
+ message(
2268
+ "Note: YMMV_TOKEN is set and still authenticates API calls. Unset it to stop using that token."
2269
+ )
2270
+ );
2271
+ }
2272
+ break;
2273
+ case "update":
2274
+ await runUpdate();
1702
2275
  break;
1703
2276
  case "help":
1704
2277
  console.log(help(palette(colorEnabled())));
1705
2278
  break;
1706
2279
  case "version":
1707
2280
  printVersion();
2281
+ await printLatestHint();
1708
2282
  break;
1709
2283
  case "error":
1710
2284
  console.error(message(cmd.message));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {