ymmv-cli 0.9.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 +3 -1
  2. package/dist/cli.js +102 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -68,7 +68,9 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
68
68
  ## Environment variables
69
69
 
70
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.
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.
72
74
  - `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
73
75
  the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
74
76
  `ymmv login` / `ymmv logout` keep acting on the stored login. Viewing (`ymmv <handle>`) also
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;
@@ -390,10 +393,7 @@ var CTRL_RE = new RegExp(
390
393
  `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
391
394
  "g"
392
395
  );
393
- var BIDI_RE = new RegExp(
394
- `[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
395
- "g"
396
- );
396
+ var BIDI_RE = new RegExp("\\p{Bidi_Control}", "gu");
397
397
  function sanitizeValue(value) {
398
398
  return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
399
399
  }
@@ -545,6 +545,7 @@ function notFound(handle, color, base) {
545
545
 
546
546
  // src/http.ts
547
547
  var REQUEST_TIMEOUT_MS = 3e4;
548
+ var REVOKE_CAP_MS = 5e3;
548
549
  var TIMEOUT_TEXT = "request timed out";
549
550
  function isTimeoutError(err) {
550
551
  return err instanceof Error && (err.name === "TimeoutError" || err.cause instanceof Error && err.cause.name === "TimeoutError");
@@ -683,6 +684,12 @@ async function bodyJson(res) {
683
684
  return null;
684
685
  }
685
686
  }
687
+ var MintRejected = class extends Error {
688
+ constructor(msg) {
689
+ super(msg);
690
+ this.name = "MintRejected";
691
+ }
692
+ };
686
693
  async function mintYmmvToken(accessToken) {
687
694
  const res = await safeFetch(
688
695
  `${BASE}/api/v1/auth/token`,
@@ -724,19 +731,30 @@ async function mintYmmvToken(accessToken) {
724
731
  throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
725
732
  }
726
733
  const data = await bodyJson(res);
727
- if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
728
- throw new Error(
729
- `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
730
747
  );
731
748
  }
732
- return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
749
+ return { token, handle, github_id: data.github_id };
733
750
  }
734
- async function revokeYmmvToken(token) {
751
+ async function revokeYmmvToken(token, signal) {
735
752
  const res = await safeFetch(
736
753
  `${BASE}/api/v1/auth/logout`,
737
754
  {
738
755
  method: "POST",
739
756
  headers: { authorization: `Bearer ${token}` },
757
+ signal,
740
758
  // Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete
741
759
  // the local file while the server token stays live). Same guard as mint + publish/delete.
742
760
  redirect: "manual"
@@ -769,7 +787,12 @@ async function saveToken(data) {
769
787
  if (process.platform !== "win32") await chmod(dir, 448).catch(() => {
770
788
  });
771
789
  const tmp = `${path}.${randomUUID()}.tmp`;
772
- 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
+ };
773
796
  try {
774
797
  await writeFile(tmp, JSON.stringify(stored), { mode: 384 });
775
798
  if (process.platform !== "win32") await chmod(tmp, 384);
@@ -790,10 +813,17 @@ async function readTokenFile() {
790
813
  }
791
814
  async function loadToken() {
792
815
  const parsed = await readTokenFile();
793
- 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) {
794
819
  return null;
795
820
  }
796
- 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
+ };
797
827
  }
798
828
  async function peekCredential() {
799
829
  const parsed = await readTokenFile();
@@ -802,7 +832,13 @@ async function peekCredential() {
802
832
  async function loadCredential() {
803
833
  const envToken = process.env.YMMV_TOKEN || "";
804
834
  if (envToken !== "") {
805
- 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
+ };
806
842
  }
807
843
  const stored = await loadToken();
808
844
  return stored ? { ...stored, source: "file" } : null;
@@ -950,16 +986,16 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
950
986
  )
951
987
  );
952
988
  const accessToken = await pollForToken(dc, deps);
953
- const { token, handle } = await mintYmmvToken(accessToken);
989
+ const minted = await mintYmmvToken(accessToken);
954
990
  const replaced = await peekCredential();
955
991
  try {
956
- await saveToken({ token, handle });
992
+ await saveToken(minted);
957
993
  } catch (e) {
958
- await revokeYmmvToken(token).catch(() => {
994
+ await revokeYmmvToken(minted.token).catch(() => {
959
995
  });
960
996
  throw e;
961
997
  }
962
- if (replaced && replaced.base === BASE && replaced.token !== token) {
998
+ if (replaced && replaced.base === BASE && replaced.token !== minted.token) {
963
999
  try {
964
1000
  await revokeYmmvToken(replaced.token);
965
1001
  } catch {
@@ -968,7 +1004,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
968
1004
  }
969
1005
  console.log(
970
1006
  message(
971
- 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)."
972
1008
  )
973
1009
  );
974
1010
  }
@@ -981,18 +1017,30 @@ var PublishRefusal = class extends Error {
981
1017
  }
982
1018
  };
983
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`.";
984
1021
  async function rateLimitMessage(res) {
985
1022
  return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
986
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
+ }
987
1035
  async function ensureLogin() {
988
1036
  const existing = await loadCredential();
989
1037
  if (existing) return existing;
990
1038
  await login();
991
1039
  const fresh = await loadCredential();
992
- if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
1040
+ if (!fresh) throw new Error(NOT_PERSISTED);
993
1041
  return fresh;
994
1042
  }
995
- async function publishProfile(profile) {
1043
+ async function publishProfile(profile, expected) {
996
1044
  const send = (c) => safeFetch(
997
1045
  `${BASE}/api/v1/profile`,
998
1046
  {
@@ -1006,8 +1054,13 @@ async function publishProfile(profile) {
1006
1054
  },
1007
1055
  BASE
1008
1056
  );
1009
- let cred = await ensureLogin();
1010
- 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) {
1011
1064
  throw new PublishRefusal(
1012
1065
  "The stored login changed while this command was running. Re-run it under the current account."
1013
1066
  );
@@ -1020,19 +1073,35 @@ async function publishProfile(profile) {
1020
1073
  );
1021
1074
  }
1022
1075
  const was401 = res.status === 401;
1076
+ const before = cred;
1023
1077
  console.log(
1024
1078
  message(
1025
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."
1026
1080
  )
1027
1081
  );
1028
1082
  if (was401) await deleteToken();
1029
- await login();
1030
- cred = await ensureLogin();
1031
- if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
1032
- const bound = sanitizeValue(cred.handle ?? "");
1033
- throw new PublishRefusal(
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.`
1035
- );
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);
1036
1105
  }
1037
1106
  res = await send(cred);
1038
1107
  if (res.status === 401) throw new PublishRefusal("Authentication failed. Run `ymmv login`.");
@@ -1576,7 +1645,7 @@ async function publish(io) {
1576
1645
  }
1577
1646
  const entries = assemble();
1578
1647
  showCard(entries);
1579
- printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1648
+ printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1580
1649
  return;
1581
1650
  }
1582
1651
  try {
@@ -1592,7 +1661,7 @@ async function publish(io) {
1592
1661
  );
1593
1662
  if (ans === "y") {
1594
1663
  try {
1595
- printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
1664
+ printPublished(await publishProfile(newProfile(handle, entries, extras), cred), color);
1596
1665
  return;
1597
1666
  } catch (e) {
1598
1667
  if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
@@ -1671,7 +1740,7 @@ async function runSet(target) {
1671
1740
  process.exitCode = 1;
1672
1741
  return;
1673
1742
  }
1674
- const res = await publishProfile(newProfile(handle, entries, extras));
1743
+ const res = await publishProfile(newProfile(handle, entries, extras), cred);
1675
1744
  const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
1676
1745
  console.log(message(`${line}${pagePointer(res.handle)}`));
1677
1746
  }
@@ -1694,7 +1763,7 @@ async function runUnset(target) {
1694
1763
  );
1695
1764
  return;
1696
1765
  }
1697
- const res = await publishProfile(newProfile(handle, entries, extras));
1766
+ const res = await publishProfile(newProfile(handle, entries, extras), cred);
1698
1767
  const line = target.kind === "curated" ? `Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").` : `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`;
1699
1768
  console.log(message(`${line}${pagePointer(res.handle)}`));
1700
1769
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.9.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": {