ymmv-cli 0.8.0 → 0.10.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 +19 -5
  2. package/dist/cli.js +411 -48
  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
@@ -47,7 +53,10 @@ via npm Trusted Publishing, with provenance.
47
53
  - `ymmv unset editor` removes one value (`ymmv set editor -` works too); `ymmv unset --extra "Keyboard"` removes an extra
48
54
  - `ymmv delete` removes your profile (`ymmv delete -y` skips the confirm, for scripts)
49
55
  - `ymmv login` / `ymmv logout` sign in / out
50
- - `ymmv version` prints the CLI version
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)
51
60
 
52
61
  Values are capped at 256 characters and extra labels at 64; a profile holds up
53
62
  to 32 extras.
@@ -59,7 +68,9 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
59
68
  ## Environment variables
60
69
 
61
70
  - `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.
71
+ - `YMMV_API` points the CLI at a different Worker (development/staging). Bare origin only. That
72
+ Worker must be deployed with or after this CLI release: `ymmv login` requires the account id the
73
+ login response carries and refuses (revoking what it minted) otherwise.
63
74
  - `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
64
75
  the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
65
76
  `ymmv login` / `ymmv logout` keep acting on the stored login. Viewing (`ymmv <handle>`) also
@@ -68,6 +79,9 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
68
79
  - `YMMV_HANDLE` names the GitHub username `YMMV_TOKEN` belongs to. Required for `ymmv -y` and
69
80
  `ymmv set`/`unset` under an env token (there is no server lookup for it); ignored without
70
81
  `YMMV_TOKEN`.
82
+ - `YMMV_NO_UPDATE_CHECK` disables the startup check for newer releases (the ecosystem-standard
83
+ `NO_UPDATE_NOTIFIER` works too). The check is also off automatically under `CI`, in pipes,
84
+ and in dev builds; it never blocks or fails a command.
71
85
 
72
86
  ## Publishing from CI
73
87
 
@@ -78,7 +92,7 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
78
92
  `~/.config/ymmv/token.json` (Linux), `~/Library/Preferences/ymmv/token.json` (macOS),
79
93
  `%APPDATA%\ymmv\Config\token.json` (Windows).
80
94
  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.
95
+ 4. Run `npx ymmv-cli@latest -y` in the job.
82
96
 
83
97
  Two things to know:
84
98
 
package/dist/cli.js CHANGED
@@ -259,6 +259,9 @@ function diff(mine, theirs) {
259
259
 
260
260
  // ../shared/dist/github.js
261
261
  var GITHUB_CLIENT_ID = "Ov23liMoD29eizQcN1KZ";
262
+ function isGithubId(x) {
263
+ return typeof x === "number" && Number.isSafeInteger(x) && x > 0;
264
+ }
262
265
 
263
266
  // ../shared/dist/types.js
264
267
  var SCHEMA_VERSION = 1;
@@ -349,7 +352,8 @@ var CLI_VERBS = [
349
352
  "view",
350
353
  "help",
351
354
  "publish",
352
- "version"
355
+ "version",
356
+ "update"
353
357
  ];
354
358
  var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
355
359
  var RESERVED_SET = new Set(RESERVED);
@@ -389,10 +393,7 @@ var CTRL_RE = new RegExp(
389
393
  `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
390
394
  "g"
391
395
  );
392
- var BIDI_RE = new RegExp(
393
- `[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
394
- "g"
395
- );
396
+ var BIDI_RE = new RegExp("\\p{Bidi_Control}", "gu");
396
397
  function sanitizeValue(value) {
397
398
  return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
398
399
  }
@@ -417,10 +418,10 @@ function isHttpUrl(value) {
417
418
  return HTTP_URL_RE.test(value.trim());
418
419
  }
419
420
  var NO_OSC8_TERMS = /* @__PURE__ */ new Set(["linux", "dumb"]);
420
- function link(url, color, term = process.env.TERM) {
421
+ function link(url, color, term = process.env.TERM, label) {
421
422
  const clean = sanitizeValue(url).trim();
422
423
  if (!color) return clean;
423
- const text = `${CODES.amber}${displayUrl(clean)}${CODES.reset}`;
424
+ const text = `${CODES.amber}${label ? sanitizeValue(label) : displayUrl(clean)}${CODES.reset}`;
424
425
  if (term !== void 0 && NO_OSC8_TERMS.has(term)) return text;
425
426
  return `${OSC}8;;${clean}${ST}${text}${OSC}8;;${ST}`;
426
427
  }
@@ -539,11 +540,12 @@ function nudge(color) {
539
540
  function notFound(handle, color, base) {
540
541
  return `
541
542
  no ymmv profile for "${sanitizeValue(handle)}" yet.
542
- publish one at ${link(base, color)} with: npx ymmv-cli`;
543
+ publish one at ${link(base, color)} with: npx ymmv-cli@latest`;
543
544
  }
544
545
 
545
546
  // src/http.ts
546
547
  var REQUEST_TIMEOUT_MS = 3e4;
548
+ var REVOKE_CAP_MS = 5e3;
547
549
  var TIMEOUT_TEXT = "request timed out";
548
550
  function isTimeoutError(err) {
549
551
  return err instanceof Error && (err.name === "TimeoutError" || err.cause instanceof Error && err.cause.name === "TimeoutError");
@@ -626,9 +628,6 @@ async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
626
628
  }
627
629
  }
628
630
 
629
- // src/index.ts
630
- import { readFileSync as readFileSync2 } from "fs";
631
-
632
631
  // src/config.ts
633
632
  function normalizeBase(raw) {
634
633
  return raw.replace(/\/+$/, "");
@@ -685,6 +684,12 @@ async function bodyJson(res) {
685
684
  return null;
686
685
  }
687
686
  }
687
+ var MintRejected = class extends Error {
688
+ constructor(msg) {
689
+ super(msg);
690
+ this.name = "MintRejected";
691
+ }
692
+ };
688
693
  async function mintYmmvToken(accessToken) {
689
694
  const res = await safeFetch(
690
695
  `${BASE}/api/v1/auth/token`,
@@ -726,19 +731,30 @@ async function mintYmmvToken(accessToken) {
726
731
  throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
727
732
  }
728
733
  const data = await bodyJson(res);
729
- if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
730
- throw new Error(
731
- `Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`
734
+ const unexpected = `Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`;
735
+ if (!data) throw new Error(unexpected);
736
+ const token = typeof data.token === "string" && data.token.length > 0 ? data.token : null;
737
+ const handle = data.handle === null ? null : typeof data.handle === "string" ? sanitizeValue(data.handle) : void 0;
738
+ if (token === null || handle === void 0 || handle !== null && !isValidHandle(handle) || !isGithubId(data.github_id)) {
739
+ let revokeFailed = false;
740
+ if (token !== null) {
741
+ await revokeYmmvToken(token, AbortSignal.timeout(REVOKE_CAP_MS)).catch(() => {
742
+ revokeFailed = true;
743
+ });
744
+ }
745
+ throw new MintRejected(
746
+ revokeFailed ? `${unexpected} The login the server minted could not be revoked.` : unexpected
732
747
  );
733
748
  }
734
- return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
749
+ return { token, handle, github_id: data.github_id };
735
750
  }
736
- async function revokeYmmvToken(token) {
751
+ async function revokeYmmvToken(token, signal) {
737
752
  const res = await safeFetch(
738
753
  `${BASE}/api/v1/auth/logout`,
739
754
  {
740
755
  method: "POST",
741
756
  headers: { authorization: `Bearer ${token}` },
757
+ signal,
742
758
  // Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete
743
759
  // the local file while the server token stays live). Same guard as mint + publish/delete.
744
760
  redirect: "manual"
@@ -771,7 +787,12 @@ async function saveToken(data) {
771
787
  if (process.platform !== "win32") await chmod(dir, 448).catch(() => {
772
788
  });
773
789
  const tmp = `${path}.${randomUUID()}.tmp`;
774
- const stored = { base: BASE, token: data.token, handle: data.handle };
790
+ const stored = {
791
+ base: BASE,
792
+ token: data.token,
793
+ handle: data.handle,
794
+ github_id: data.github_id
795
+ };
775
796
  try {
776
797
  await writeFile(tmp, JSON.stringify(stored), { mode: 384 });
777
798
  if (process.platform !== "win32") await chmod(tmp, 384);
@@ -792,10 +813,17 @@ async function readTokenFile() {
792
813
  }
793
814
  async function loadToken() {
794
815
  const parsed = await readTokenFile();
795
- if (!parsed || parsed.base !== BASE || typeof parsed.token !== "string" || parsed.token === "" || parsed.handle !== null && typeof parsed.handle !== "string") {
816
+ const rawId = parsed?.github_id;
817
+ const idOk = rawId === void 0 || rawId === null || isGithubId(rawId);
818
+ if (!parsed || parsed.base !== BASE || typeof parsed.token !== "string" || parsed.token === "" || parsed.handle !== null && typeof parsed.handle !== "string" || !idOk) {
796
819
  return null;
797
820
  }
798
- return parsed;
821
+ return {
822
+ base: parsed.base,
823
+ token: parsed.token,
824
+ handle: parsed.handle === "" ? null : parsed.handle,
825
+ github_id: isGithubId(rawId) ? rawId : null
826
+ };
799
827
  }
800
828
  async function peekCredential() {
801
829
  const parsed = await readTokenFile();
@@ -804,7 +832,13 @@ async function peekCredential() {
804
832
  async function loadCredential() {
805
833
  const envToken = process.env.YMMV_TOKEN || "";
806
834
  if (envToken !== "") {
807
- return { base: BASE, token: envToken, handle: process.env.YMMV_HANDLE || null, source: "env" };
835
+ return {
836
+ base: BASE,
837
+ token: envToken,
838
+ handle: process.env.YMMV_HANDLE || null,
839
+ github_id: null,
840
+ source: "env"
841
+ };
808
842
  }
809
843
  const stored = await loadToken();
810
844
  return stored ? { ...stored, source: "file" } : null;
@@ -952,16 +986,16 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
952
986
  )
953
987
  );
954
988
  const accessToken = await pollForToken(dc, deps);
955
- const { token, handle } = await mintYmmvToken(accessToken);
989
+ const minted = await mintYmmvToken(accessToken);
956
990
  const replaced = await peekCredential();
957
991
  try {
958
- await saveToken({ token, handle });
992
+ await saveToken(minted);
959
993
  } catch (e) {
960
- await revokeYmmvToken(token).catch(() => {
994
+ await revokeYmmvToken(minted.token).catch(() => {
961
995
  });
962
996
  throw e;
963
997
  }
964
- if (replaced && replaced.base === BASE && replaced.token !== token) {
998
+ if (replaced && replaced.base === BASE && replaced.token !== minted.token) {
965
999
  try {
966
1000
  await revokeYmmvToken(replaced.token);
967
1001
  } catch {
@@ -970,7 +1004,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
970
1004
  }
971
1005
  console.log(
972
1006
  message(
973
- handle ? `Logged in as ${handle}.` : "Logged in. No handle bound (your GitHub username is a reserved word)."
1007
+ minted.handle ? `Logged in as ${minted.handle}.` : "Logged in. No handle bound (your GitHub username is a reserved word)."
974
1008
  )
975
1009
  );
976
1010
  }
@@ -983,18 +1017,30 @@ var PublishRefusal = class extends Error {
983
1017
  }
984
1018
  };
985
1019
  var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (revoked or expired).";
1020
+ var NOT_PERSISTED = "Login did not persist a token. Run `ymmv login`.";
986
1021
  async function rateLimitMessage(res) {
987
1022
  return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
988
1023
  }
1024
+ async function loginOrRefuse() {
1025
+ try {
1026
+ await login();
1027
+ } catch (e) {
1028
+ if (e instanceof MintRejected) throw new PublishRefusal(e.message);
1029
+ throw e;
1030
+ }
1031
+ const fresh = await loadCredential();
1032
+ if (!fresh) throw new PublishRefusal(NOT_PERSISTED);
1033
+ return fresh;
1034
+ }
989
1035
  async function ensureLogin() {
990
1036
  const existing = await loadCredential();
991
1037
  if (existing) return existing;
992
1038
  await login();
993
1039
  const fresh = await loadCredential();
994
- if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
1040
+ if (!fresh) throw new Error(NOT_PERSISTED);
995
1041
  return fresh;
996
1042
  }
997
- async function publishProfile(profile) {
1043
+ async function publishProfile(profile, expected) {
998
1044
  const send = (c) => safeFetch(
999
1045
  `${BASE}/api/v1/profile`,
1000
1046
  {
@@ -1008,8 +1054,13 @@ async function publishProfile(profile) {
1008
1054
  },
1009
1055
  BASE
1010
1056
  );
1011
- let cred = await ensureLogin();
1012
- if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
1057
+ let cred = await loadCredential();
1058
+ if (!cred) {
1059
+ console.log(message("Not logged in. Logging in to publish."));
1060
+ cred = await loginOrRefuse();
1061
+ }
1062
+ const idDrifted = expected.source === "file" && (expected.github_id === null ? cred.github_id !== null : cred.github_id !== expected.github_id);
1063
+ if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase() || idDrifted) {
1013
1064
  throw new PublishRefusal(
1014
1065
  "The stored login changed while this command was running. Re-run it under the current account."
1015
1066
  );
@@ -1022,19 +1073,35 @@ async function publishProfile(profile) {
1022
1073
  );
1023
1074
  }
1024
1075
  const was401 = res.status === 401;
1076
+ const before = cred;
1025
1077
  console.log(
1026
1078
  message(
1027
1079
  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
1080
  )
1029
1081
  );
1030
1082
  if (was401) await deleteToken();
1031
- await login();
1032
- cred = await ensureLogin();
1033
- if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
1034
- const bound = sanitizeValue(cred.handle ?? "");
1035
- throw new PublishRefusal(
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.`
1037
- );
1083
+ cred = await loginOrRefuse();
1084
+ const known = before.github_id !== null;
1085
+ const idChanged = known && cred.github_id !== before.github_id;
1086
+ const sameHandle = (cred.handle ?? "").toLowerCase() === profile.handle.toLowerCase();
1087
+ if (!sameHandle || idChanged) {
1088
+ const mine = sanitizeValue(profile.handle);
1089
+ const bound = cred.handle === null ? null : sanitizeValue(cred.handle);
1090
+ const reRun = "Nothing was published. Re-run under the account you meant.";
1091
+ const otherAccount = `The re-login bound a different account ("${bound}", not "${mine}"). ${reRun}`;
1092
+ if (idChanged) {
1093
+ throw new PublishRefusal(
1094
+ sameHandle ? `The handle "${mine}" now belongs to a different GitHub account. ${reRun}` : bound === null ? `The re-login bound a different GitHub account. ${reRun}` : otherAccount
1095
+ );
1096
+ }
1097
+ if (bound === null) {
1098
+ throw new PublishRefusal(
1099
+ "Your login no longer binds a handle (your GitHub username is a reserved word). Nothing was published."
1100
+ );
1101
+ }
1102
+ const rebound = `Your login now binds "${bound}". Nothing was published. Re-run the command to publish under it.`;
1103
+ if (known) throw new PublishRefusal(rebound);
1104
+ throw new PublishRefusal(was401 ? otherAccount : rebound);
1038
1105
  }
1039
1106
  res = await send(cred);
1040
1107
  if (res.status === 401) throw new PublishRefusal("Authentication failed. Run `ymmv login`.");
@@ -1578,7 +1645,7 @@ async function publish(io) {
1578
1645
  }
1579
1646
  const entries = assemble();
1580
1647
  showCard(entries);
1581
- printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1648
+ printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1582
1649
  return;
1583
1650
  }
1584
1651
  try {
@@ -1594,7 +1661,7 @@ async function publish(io) {
1594
1661
  );
1595
1662
  if (ans === "y") {
1596
1663
  try {
1597
- printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1664
+ printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1598
1665
  return;
1599
1666
  } catch (e) {
1600
1667
  if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
@@ -1673,7 +1740,7 @@ async function runSet(target) {
1673
1740
  process.exitCode = 1;
1674
1741
  return;
1675
1742
  }
1676
- const res = await publishProfile(newProfile(handle, entries, extras));
1743
+ const res = await publishProfile(newProfile(handle, entries, extras), cred);
1677
1744
  const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
1678
1745
  console.log(message(`${line}${pagePointer(res.handle)}`));
1679
1746
  }
@@ -1696,7 +1763,7 @@ async function runUnset(target) {
1696
1763
  );
1697
1764
  return;
1698
1765
  }
1699
- const res = await publishProfile(newProfile(handle, entries, extras));
1766
+ const res = await publishProfile(newProfile(handle, entries, extras), cred);
1700
1767
  const line = target.kind === "curated" ? `Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").` : `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`;
1701
1768
  console.log(message(`${line}${pagePointer(res.handle)}`));
1702
1769
  }
@@ -1827,7 +1894,7 @@ function resolveArg(argv) {
1827
1894
  }
1828
1895
  return { kind: "publish", yes: true };
1829
1896
  }
1830
- if (first === "login" || first === "logout") return noArgs(first, rest);
1897
+ if (first === "login" || first === "logout" || first === "update") return noArgs(first, rest);
1831
1898
  if (first === "publish") {
1832
1899
  return yesOnly("usage: ymmv publish [-y]", rest, (yes) => ({ kind: "publish", yes }));
1833
1900
  }
@@ -1877,6 +1944,274 @@ function reservedError(handle, hintVerbs = false) {
1877
1944
  };
1878
1945
  }
1879
1946
 
1947
+ // src/update.ts
1948
+ import { spawn as nodeSpawn } from "child_process";
1949
+ import { homedir } from "os";
1950
+
1951
+ // src/update-check.ts
1952
+ import { randomUUID as randomUUID2 } from "crypto";
1953
+ import { readFileSync as readFileSync2, realpathSync } from "fs";
1954
+ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1955
+ import { dirname as dirname2, join as join2 } from "path";
1956
+ import envPaths2 from "env-paths";
1957
+ var REGISTRY_URL = "https://registry.npmjs.org/ymmv-cli/latest";
1958
+ var RELEASES_URL = "https://github.com/ymmv-fyi/ymmv/releases";
1959
+ var FETCH_TIMEOUT_MS = 2e3;
1960
+ var FINISH_GRACE_MS = 500;
1961
+ var SUCCESS_TTL_MS = 24 * 60 * 60 * 1e3;
1962
+ var FAILURE_TTL_MS = 6 * 60 * 60 * 1e3;
1963
+ var VERSION_FRESH_MS = 7 * 24 * 60 * 60 * 1e3;
1964
+ function ownVersion() {
1965
+ try {
1966
+ const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
1967
+ return typeof pkg.version === "string" ? pkg.version : null;
1968
+ } catch {
1969
+ return null;
1970
+ }
1971
+ }
1972
+ function parseTriple(v) {
1973
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v);
1974
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
1975
+ }
1976
+ function isNewer(latest, current) {
1977
+ const l = parseTriple(latest);
1978
+ const c = parseTriple(current);
1979
+ if (!l || !c) return false;
1980
+ for (let i = 0; i < 3; i++) {
1981
+ if (l[i] !== c[i]) return l[i] > c[i];
1982
+ }
1983
+ return false;
1984
+ }
1985
+ function updateCachePath() {
1986
+ return join2(envPaths2("ymmv", { suffix: "" }).config, "update-check.json");
1987
+ }
1988
+ async function readCache(path, now) {
1989
+ try {
1990
+ const parsed = JSON.parse(await readFile2(path, "utf8"));
1991
+ if (typeof parsed !== "object" || parsed === null) return null;
1992
+ const c = parsed;
1993
+ if (typeof c.lastCheckedAt !== "number" || typeof c.ok !== "boolean") return null;
1994
+ if (!Number.isFinite(c.lastCheckedAt) || c.lastCheckedAt > now) return null;
1995
+ if (c.latest !== void 0 && typeof c.latest !== "string") return null;
1996
+ if (c.latestAt !== void 0 && (!Number.isFinite(c.latestAt) || c.latestAt > now)) return null;
1997
+ return { lastCheckedAt: c.lastCheckedAt, ok: c.ok, latest: c.latest, latestAt: c.latestAt };
1998
+ } catch {
1999
+ return null;
2000
+ }
2001
+ }
2002
+ async function writeCache(path, cache) {
2003
+ const tmp = `${path}.${randomUUID2()}.tmp`;
2004
+ try {
2005
+ await mkdir2(dirname2(path), { recursive: true });
2006
+ await writeFile2(tmp, JSON.stringify(cache));
2007
+ await rename2(tmp, path);
2008
+ } catch {
2009
+ await rm2(tmp, { force: true }).catch(() => {
2010
+ });
2011
+ }
2012
+ }
2013
+ function binRealpath() {
2014
+ const raw = process.argv[1];
2015
+ if (!raw) return void 0;
2016
+ try {
2017
+ return realpathSync(raw);
2018
+ } catch {
2019
+ return raw;
2020
+ }
2021
+ }
2022
+ var NPM_PREFIX_ROOTS = /* @__PURE__ */ new Set(["usr", "local", "homebrew", "nodejs", "node", "installation"]);
2023
+ var VERSIONISH_RE = /^v?\d+(\.\d+)*$/;
2024
+ function detectInstallMethod(binPath) {
2025
+ if (!binPath) return "unknown";
2026
+ const segs = binPath.split(/[\\/]+/).filter(Boolean).map((s) => s.toLowerCase());
2027
+ const has = (name) => segs.includes(name);
2028
+ if (has("_npx")) return "ephemeral";
2029
+ if (has("dlx") || segs.some((s) => s.startsWith("dlx-"))) return "ephemeral";
2030
+ if (segs.some((s) => s.startsWith("bunx-"))) return "ephemeral";
2031
+ if (has(".bun") && has("cache")) return "ephemeral";
2032
+ if (has(".bun") && (has("global") || has("bin"))) return "bun-global";
2033
+ if (has(".volta")) return "unknown";
2034
+ if (segs.some((s, i) => s === "pnpm" && segs[i + 1] === "global")) return "pnpm-global";
2035
+ if (has(".pnpm")) return "unknown";
2036
+ const npmGlobal = segs.some(
2037
+ (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")
2038
+ );
2039
+ if (npmGlobal) return "npm-global";
2040
+ return "unknown";
2041
+ }
2042
+ function npxInvocation(latest) {
2043
+ const triple = latest ? parseTriple(latest) : null;
2044
+ return `npx ymmv-cli@${triple ? triple.join(".") : "latest"}`;
2045
+ }
2046
+ function formatUpdateNotice(current, latest, method, color, term) {
2047
+ const cur = parseTriple(current);
2048
+ const lat = parseTriple(latest);
2049
+ if (!cur || !lat || !isNewer(lat.join("."), cur.join("."))) return null;
2050
+ const latestClean = lat.join(".");
2051
+ const currentClean = cur.join(".");
2052
+ const cmd = method === "ephemeral" ? npxInvocation(latestClean) : "ymmv update";
2053
+ const c = palette(color);
2054
+ const headline = `ymmv-cli ${latestClean} is out`;
2055
+ const linked = color ? link(RELEASES_URL, true, term, headline) : headline;
2056
+ return `${linked} ${c.faint}(you have ${currentClean})${c.reset} \u2192 run ${c.bold}${cmd}${c.reset}`;
2057
+ }
2058
+ function updatesOptedOut(env) {
2059
+ if (env.YMMV_NO_UPDATE_CHECK) return true;
2060
+ if (env.NO_UPDATE_NOTIFIER) return true;
2061
+ if (env.CI && env.CI !== "false") return true;
2062
+ return false;
2063
+ }
2064
+ async function readCachedLatest(deps = {}) {
2065
+ const now = (deps.now ?? Date.now)();
2066
+ const cache = await readCache(deps.cachePath ?? updateCachePath(), now);
2067
+ if (!cache?.latest || cache.latestAt === void 0) return null;
2068
+ if (now - cache.latestAt > VERSION_FRESH_MS) return null;
2069
+ const triple = parseTriple(cache.latest);
2070
+ return triple ? triple.join(".") : null;
2071
+ }
2072
+ var GRACE = /* @__PURE__ */ Symbol("grace");
2073
+ function startUpdateCheck(deps = {}) {
2074
+ const env = deps.env ?? process.env;
2075
+ const nowFn = deps.now ?? Date.now;
2076
+ const stderrIsTTY = deps.stderrIsTTY ?? Boolean(process.stderr.isTTY);
2077
+ const inert = { finish: async () => null, abort: () => {
2078
+ } };
2079
+ if (updatesOptedOut(env)) return inert;
2080
+ if (!stderrIsTTY) return inert;
2081
+ const current = deps.currentVersion !== void 0 ? deps.currentVersion : ownVersion();
2082
+ const currentTriple = current === null ? null : parseTriple(current);
2083
+ if (!currentTriple || currentTriple.join(".") === "0.0.0") return inert;
2084
+ const currentClean = currentTriple.join(".");
2085
+ const cachePath = deps.cachePath ?? updateCachePath();
2086
+ const doFetch = deps.fetch ?? globalThis.fetch;
2087
+ const method = detectInstallMethod(deps.binPath ?? binRealpath());
2088
+ const color = useColor(env, stderrIsTTY);
2089
+ const term = env.TERM;
2090
+ const controller = new AbortController();
2091
+ const notice = (latest) => latest ? formatUpdateNotice(currentClean, latest, method, color, term) : null;
2092
+ const check = (async () => {
2093
+ const cache = await readCache(cachePath, nowFn());
2094
+ if (cache && nowFn() - cache.lastCheckedAt < (cache.ok ? SUCCESS_TTL_MS : FAILURE_TTL_MS)) {
2095
+ return notice(cache.latest);
2096
+ }
2097
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
2098
+ timeout.unref?.();
2099
+ let fetched = null;
2100
+ try {
2101
+ const res = await doFetch(REGISTRY_URL, {
2102
+ signal: controller.signal,
2103
+ headers: { accept: "application/json" }
2104
+ });
2105
+ if (res.ok) {
2106
+ const body = await res.json();
2107
+ if (typeof body.version === "string") fetched = body.version;
2108
+ }
2109
+ } catch {
2110
+ } finally {
2111
+ clearTimeout(timeout);
2112
+ }
2113
+ const triple = fetched === null ? null : parseTriple(fetched);
2114
+ const canonical2 = triple ? triple.join(".") : void 0;
2115
+ const now = nowFn();
2116
+ await writeCache(cachePath, {
2117
+ lastCheckedAt: now,
2118
+ ok: canonical2 !== void 0,
2119
+ latest: canonical2 ?? cache?.latest,
2120
+ latestAt: canonical2 !== void 0 ? now : cache?.latestAt
2121
+ });
2122
+ return notice(canonical2 ?? cache?.latest);
2123
+ })().catch(() => null);
2124
+ return {
2125
+ async finish() {
2126
+ const result = await new Promise((resolve) => {
2127
+ const timer = setTimeout(() => resolve(GRACE), FINISH_GRACE_MS);
2128
+ timer.unref?.();
2129
+ void check.then((v) => {
2130
+ clearTimeout(timer);
2131
+ resolve(v);
2132
+ });
2133
+ });
2134
+ if (result === GRACE) {
2135
+ controller.abort();
2136
+ return null;
2137
+ }
2138
+ return result;
2139
+ },
2140
+ abort() {
2141
+ controller.abort();
2142
+ }
2143
+ };
2144
+ }
2145
+
2146
+ // src/update.ts
2147
+ var UPGRADE_COMMANDS = {
2148
+ "npm-global": "npm i -g ymmv-cli@latest",
2149
+ "pnpm-global": "pnpm add -g ymmv-cli@latest",
2150
+ "bun-global": "bun add -g ymmv-cli@latest"
2151
+ };
2152
+ var MANUAL_LIST = Object.values(UPGRADE_COMMANDS).map((cmd) => ` ${cmd}`).join("\n");
2153
+ var NOT_FOUND_CODES = /* @__PURE__ */ new Set([127, 9009]);
2154
+ function manualFallback(intro) {
2155
+ return `${intro} Update with the command that matches your setup:
2156
+ ${MANUAL_LIST}`;
2157
+ }
2158
+ async function runUpdate(deps = {}) {
2159
+ const method = detectInstallMethod(deps.binPath ?? binRealpath());
2160
+ const c = palette(colorEnabled());
2161
+ if (method === "ephemeral") {
2162
+ const cached = await readCachedLatest({ now: deps.now, cachePath: deps.cachePath });
2163
+ const current = deps.currentVersion !== void 0 ? deps.currentVersion : ownVersion();
2164
+ const pin = cached !== null && current !== null && isNewer(cached, current) ? cached : null;
2165
+ console.log(
2166
+ message(
2167
+ `This run came from a package runner cache (npx-style), so there is no install to update.
2168
+ Next time run: ${c.bold}${npxInvocation(pin)}${c.reset}`
2169
+ )
2170
+ );
2171
+ return;
2172
+ }
2173
+ if (method === "unknown") {
2174
+ console.log(message(manualFallback("Couldn't tell how ymmv-cli was installed.")));
2175
+ return;
2176
+ }
2177
+ const cmd = UPGRADE_COMMANDS[method];
2178
+ const doSpawn = deps.spawn ?? nodeSpawn;
2179
+ const failSpawn = () => {
2180
+ console.error(message(manualFallback(`Couldn't run \`${cmd}\`.`)));
2181
+ process.exitCode = 1;
2182
+ };
2183
+ console.log(message(`${c.faint}running${c.reset} ${c.bold}${cmd}${c.reset}`));
2184
+ await new Promise((resolve) => {
2185
+ let child;
2186
+ try {
2187
+ child = doSpawn(cmd, { stdio: "inherit", shell: true, cwd: homedir() });
2188
+ } catch {
2189
+ failSpawn();
2190
+ resolve();
2191
+ return;
2192
+ }
2193
+ let settled = false;
2194
+ const settle = (fn) => {
2195
+ if (settled) return;
2196
+ settled = true;
2197
+ fn();
2198
+ resolve();
2199
+ };
2200
+ child.on("error", () => {
2201
+ settle(failSpawn);
2202
+ });
2203
+ child.on("exit", (code) => {
2204
+ settle(() => {
2205
+ if (code !== null && NOT_FOUND_CODES.has(code)) {
2206
+ failSpawn();
2207
+ } else if (code) {
2208
+ process.exitCode = code;
2209
+ }
2210
+ });
2211
+ });
2212
+ });
2213
+ }
2214
+
1880
2215
  // src/index.ts
1881
2216
  var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
1882
2217
 
@@ -1891,6 +2226,7 @@ ${c.faint}Usage:${c.reset}
1891
2226
  ymmv unset --extra "L" remove a free-form extra
1892
2227
  ymmv delete [-y] delete your profile (permanent; -y skips the confirm)
1893
2228
  ymmv login | logout GitHub device-flow auth
2229
+ ymmv update update ymmv-cli to the latest release
1894
2230
  ymmv help | version
1895
2231
 
1896
2232
  ${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser, window-manager,
@@ -1922,12 +2258,21 @@ async function logout() {
1922
2258
  console.log(message(revoked ? "Logged out." : "Logged out (no active session on this server)."));
1923
2259
  }
1924
2260
  function printVersion() {
1925
- try {
1926
- const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
1927
- console.log(`ymmv-cli ${pkg.version ?? "unknown"}`);
1928
- } catch {
1929
- console.log("ymmv-cli (version unknown)");
1930
- }
2261
+ const version = ownVersion();
2262
+ console.log(version === null ? "ymmv-cli (version unknown)" : `ymmv-cli ${version}`);
2263
+ }
2264
+ async function printLatestHint() {
2265
+ if (!process.stderr.isTTY) return;
2266
+ if (updatesOptedOut(process.env)) return;
2267
+ const current = ownVersion();
2268
+ const currentTriple = current === null ? null : parseTriple(current);
2269
+ if (!currentTriple || currentTriple.join(".") === "0.0.0") return;
2270
+ const latest = await readCachedLatest();
2271
+ if (latest === null || !isNewer(latest, currentTriple.join("."))) return;
2272
+ const c = palette(useColor(process.env, true));
2273
+ console.error(
2274
+ message(`${c.faint}latest: ${latest}${c.reset} \u2192 run ${c.bold}ymmv update${c.reset}`)
2275
+ );
1931
2276
  }
1932
2277
  async function interactive(run, yes) {
1933
2278
  const isTTY = Boolean(process.stdin.isTTY);
@@ -1938,9 +2283,12 @@ async function interactive(run, yes) {
1938
2283
  prompter?.close();
1939
2284
  }
1940
2285
  }
2286
+ function wantsUpdateCheck(kind) {
2287
+ return kind !== "help" && kind !== "version" && kind !== "error" && kind !== "update";
2288
+ }
1941
2289
  async function main(argv) {
1942
2290
  const cmd = resolveArg(argv);
1943
- if (cmd.kind !== "logout") {
2291
+ if (cmd.kind !== "logout" && cmd.kind !== "update") {
1944
2292
  const problem = baseProblem() ?? credentialEnvProblem();
1945
2293
  if (problem) {
1946
2294
  console.error(message(problem));
@@ -1948,6 +2296,17 @@ async function main(argv) {
1948
2296
  return;
1949
2297
  }
1950
2298
  }
2299
+ const update = wantsUpdateCheck(cmd.kind) ? startUpdateCheck() : null;
2300
+ try {
2301
+ await dispatch(cmd);
2302
+ } catch (e) {
2303
+ update?.abort();
2304
+ throw e;
2305
+ }
2306
+ const notice = await update?.finish();
2307
+ if (notice) console.error(message(notice));
2308
+ }
2309
+ async function dispatch(cmd) {
1951
2310
  switch (cmd.kind) {
1952
2311
  case "publish":
1953
2312
  await interactive(publish, cmd.yes);
@@ -1980,11 +2339,15 @@ async function main(argv) {
1980
2339
  );
1981
2340
  }
1982
2341
  break;
2342
+ case "update":
2343
+ await runUpdate();
2344
+ break;
1983
2345
  case "help":
1984
2346
  console.log(help(palette(colorEnabled())));
1985
2347
  break;
1986
2348
  case "version":
1987
2349
  printVersion();
2350
+ await printLatestHint();
1988
2351
  break;
1989
2352
  case "error":
1990
2353
  console.error(message(cmd.message));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {