ymmv-cli 0.10.0 → 0.11.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.
- package/README.md +18 -11
- package/dist/cli.js +103 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,15 +70,18 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
|
|
|
70
70
|
- `NO_COLOR` disables color output (and `FORCE_COLOR=0`/`false` force-disables it).
|
|
71
71
|
- `YMMV_API` points the CLI at a different Worker (development/staging). Bare origin only. That
|
|
72
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
|
|
73
|
+
login response carries and refuses (revoking what it minted) otherwise, and `YMMV_TOKEN` needs
|
|
74
|
+
the server's account lookup.
|
|
74
75
|
- `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
|
|
75
76
|
the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
|
|
76
|
-
`ymmv login` / `ymmv logout` keep acting on the stored login.
|
|
77
|
-
|
|
78
|
-
`YMMV_API` selects, so set the
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
`
|
|
77
|
+
`ymmv login` / `ymmv logout` keep acting on the stored login. The CLI asks the server which
|
|
78
|
+
account the token belongs to, so every command, including the you-side of a `ymmv <handle>`
|
|
79
|
+
diff, runs as that account. The token is sent to the server `YMMV_API` selects, so set the
|
|
80
|
+
two together.
|
|
81
|
+
- `YMMV_HANDLE` is optional. When set, it must name the GitHub username `YMMV_TOKEN` belongs
|
|
82
|
+
to: if the server reports a different account, `ymmv -y`, `ymmv set`/`unset`, and
|
|
83
|
+
`ymmv delete` refuse before sending anything, and `ymmv <handle>` shows the profile without
|
|
84
|
+
a diff. Ignored without `YMMV_TOKEN`.
|
|
82
85
|
- `YMMV_NO_UPDATE_CHECK` disables the startup check for newer releases (the ecosystem-standard
|
|
83
86
|
`NO_UPDATE_NOTIFIER` works too). The check is also off automatically under `CI`, in pipes,
|
|
84
87
|
and in dev builds; it never blocks or fails a command.
|
|
@@ -91,7 +94,8 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
|
|
|
91
94
|
2. Copy the `token` value from the token file:
|
|
92
95
|
`~/.config/ymmv/token.json` (Linux), `~/Library/Preferences/ymmv/token.json` (macOS),
|
|
93
96
|
`%APPDATA%\ymmv\Config\token.json` (Windows).
|
|
94
|
-
3. Set it as a CI secret named `YMMV_TOKEN
|
|
97
|
+
3. Set it as a CI secret named `YMMV_TOKEN`. Setting `YMMV_HANDLE` to your GitHub username is
|
|
98
|
+
optional, and makes the job fail if the secret ever holds another account's token.
|
|
95
99
|
4. Run `npx ymmv-cli@latest -y` in the job.
|
|
96
100
|
|
|
97
101
|
Two things to know:
|
|
@@ -100,9 +104,12 @@ Two things to know:
|
|
|
100
104
|
runs on. Values you already published always win, but curated keys you have never set get the
|
|
101
105
|
CI runner's detected values (its OS, shell, and so on). For targeted updates from CI, prefer
|
|
102
106
|
`ymmv set <key> <value>`.
|
|
103
|
-
- A rejected or revoked `YMMV_TOKEN` fails
|
|
104
|
-
to an interactive login, and the stored login
|
|
105
|
-
|
|
107
|
+
- A rejected or revoked `YMMV_TOKEN` fails `ymmv -y`, `ymmv set`/`unset`, and `ymmv delete` with
|
|
108
|
+
an error naming the variable; nothing falls back to an interactive login, and the stored login
|
|
109
|
+
file on the runner (if any) is left untouched. `ymmv <handle>` still shows the profile, with the
|
|
110
|
+
reason there is no diff on stderr, and exits 0.
|
|
111
|
+
`ymmv delete` acts on the account the token is bound to and names that account's page when
|
|
112
|
+
it asks for confirmation.
|
|
106
113
|
|
|
107
114
|
## License
|
|
108
115
|
|
package/dist/cli.js
CHANGED
|
@@ -684,6 +684,17 @@ async function bodyJson(res) {
|
|
|
684
684
|
return null;
|
|
685
685
|
}
|
|
686
686
|
}
|
|
687
|
+
var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (invalid or revoked).";
|
|
688
|
+
var ENV_TOKEN_REJECTED_MINT_AGAIN = `${ENV_TOKEN_REJECTED} Mint a new one with \`ymmv login\` on an interactive machine and update YMMV_TOKEN.`;
|
|
689
|
+
function parseIdentity(data) {
|
|
690
|
+
if (typeof data !== "object" || data === null) return null;
|
|
691
|
+
const { handle: rawHandle, github_id: id } = data;
|
|
692
|
+
const handle = rawHandle === null ? null : typeof rawHandle === "string" ? sanitizeValue(rawHandle) : void 0;
|
|
693
|
+
if (handle === void 0 || handle !== null && !isValidHandle(handle) || !isGithubId(id)) {
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
return { github_id: id, handle };
|
|
697
|
+
}
|
|
687
698
|
var MintRejected = class extends Error {
|
|
688
699
|
constructor(msg) {
|
|
689
700
|
super(msg);
|
|
@@ -734,8 +745,8 @@ async function mintYmmvToken(accessToken) {
|
|
|
734
745
|
const unexpected = `Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`;
|
|
735
746
|
if (!data) throw new Error(unexpected);
|
|
736
747
|
const token = typeof data.token === "string" && data.token.length > 0 ? data.token : null;
|
|
737
|
-
const
|
|
738
|
-
if (token === null ||
|
|
748
|
+
const identity = parseIdentity(data);
|
|
749
|
+
if (token === null || identity === null) {
|
|
739
750
|
let revokeFailed = false;
|
|
740
751
|
if (token !== null) {
|
|
741
752
|
await revokeYmmvToken(token, AbortSignal.timeout(REVOKE_CAP_MS)).catch(() => {
|
|
@@ -746,7 +757,38 @@ async function mintYmmvToken(accessToken) {
|
|
|
746
757
|
revokeFailed ? `${unexpected} The login the server minted could not be revoked.` : unexpected
|
|
747
758
|
);
|
|
748
759
|
}
|
|
749
|
-
return { token,
|
|
760
|
+
return { token, ...identity };
|
|
761
|
+
}
|
|
762
|
+
async function fetchWhoami(token) {
|
|
763
|
+
const res = await safeFetch(
|
|
764
|
+
`${BASE}/api/v1/auth/whoami`,
|
|
765
|
+
{
|
|
766
|
+
headers: { authorization: `Bearer ${token}` },
|
|
767
|
+
// Never follow a redirect: the bearer must not travel to a redirect target, and a 30x→200
|
|
768
|
+
// must not read as a verified identity. Same guard as mint, logout, publish, and delete.
|
|
769
|
+
redirect: "manual"
|
|
770
|
+
},
|
|
771
|
+
BASE
|
|
772
|
+
);
|
|
773
|
+
if (!res.ok) {
|
|
774
|
+
if (res.status === 401) throw new Error(ENV_TOKEN_REJECTED_MINT_AGAIN);
|
|
775
|
+
if (res.status === 404) {
|
|
776
|
+
throw new Error(
|
|
777
|
+
`${BASE} has no identity lookup for YMMV_TOKEN. ${process.env.YMMV_API ? "Point YMMV_API at an up-to-date server." : "The server is behind this CLI release; try again later."}`
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
throw new Error(
|
|
781
|
+
withRetryHint(
|
|
782
|
+
await serverMessage(res) ?? (res.status === 429 ? "rate limited, too many requests" : `The server couldn't look up your token (${res.status}). Try again shortly.`),
|
|
783
|
+
res
|
|
784
|
+
)
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
const identity = parseIdentity(await bodyJson(res));
|
|
788
|
+
if (!identity) {
|
|
789
|
+
throw new Error(`Unexpected response from ${BASE}. Check YMMV_API, or try again shortly.`);
|
|
790
|
+
}
|
|
791
|
+
return identity;
|
|
750
792
|
}
|
|
751
793
|
async function revokeYmmvToken(token, signal) {
|
|
752
794
|
const res = await safeFetch(
|
|
@@ -1016,8 +1058,14 @@ var PublishRefusal = class extends Error {
|
|
|
1016
1058
|
this.name = "PublishRefusal";
|
|
1017
1059
|
}
|
|
1018
1060
|
};
|
|
1019
|
-
var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (revoked or expired).";
|
|
1020
1061
|
var NOT_PERSISTED = "Login did not persist a token. Run `ymmv login`.";
|
|
1062
|
+
function assertVerified(cred) {
|
|
1063
|
+
if (cred.source === "env" && cred.github_id === null) {
|
|
1064
|
+
throw new PublishRefusal(
|
|
1065
|
+
"Internal error: the YMMV_TOKEN credential was not verified. This is a ymmv-cli bug; please report it at https://github.com/ymmv-fyi/ymmv/issues."
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1021
1069
|
async function rateLimitMessage(res) {
|
|
1022
1070
|
return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
|
|
1023
1071
|
}
|
|
@@ -1032,9 +1080,20 @@ async function loginOrRefuse() {
|
|
|
1032
1080
|
if (!fresh) throw new PublishRefusal(NOT_PERSISTED);
|
|
1033
1081
|
return fresh;
|
|
1034
1082
|
}
|
|
1083
|
+
async function verifyEnvCredential(cred) {
|
|
1084
|
+
const identity = await fetchWhoami(cred.token);
|
|
1085
|
+
const claimed = cred.handle;
|
|
1086
|
+
if (claimed !== null && claimed.toLowerCase() !== identity.handle?.toLowerCase()) {
|
|
1087
|
+
const shown = `YMMV_HANDLE is "${sanitizeValue(claimed)}"`;
|
|
1088
|
+
throw new Error(
|
|
1089
|
+
identity.handle === null ? `${shown} but the YMMV_TOKEN account has no handle bound.` : `${shown} but YMMV_TOKEN belongs to "${identity.handle}". Fix or unset YMMV_HANDLE.`
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
return { ...cred, handle: identity.handle, github_id: identity.github_id };
|
|
1093
|
+
}
|
|
1035
1094
|
async function ensureLogin() {
|
|
1036
1095
|
const existing = await loadCredential();
|
|
1037
|
-
if (existing) return existing;
|
|
1096
|
+
if (existing) return existing.source === "env" ? verifyEnvCredential(existing) : existing;
|
|
1038
1097
|
await login();
|
|
1039
1098
|
const fresh = await loadCredential();
|
|
1040
1099
|
if (!fresh) throw new Error(NOT_PERSISTED);
|
|
@@ -1054,11 +1113,13 @@ async function publishProfile(profile, expected) {
|
|
|
1054
1113
|
},
|
|
1055
1114
|
BASE
|
|
1056
1115
|
);
|
|
1057
|
-
|
|
1116
|
+
assertVerified(expected);
|
|
1117
|
+
let cred = expected.source === "env" ? expected : await loadCredential();
|
|
1058
1118
|
if (!cred) {
|
|
1059
1119
|
console.log(message("Not logged in. Logging in to publish."));
|
|
1060
1120
|
cred = await loginOrRefuse();
|
|
1061
1121
|
}
|
|
1122
|
+
assertVerified(cred);
|
|
1062
1123
|
const idDrifted = expected.source === "file" && (expected.github_id === null ? cred.github_id !== null : cred.github_id !== expected.github_id);
|
|
1063
1124
|
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase() || idDrifted) {
|
|
1064
1125
|
throw new PublishRefusal(
|
|
@@ -1069,7 +1130,11 @@ async function publishProfile(profile, expected) {
|
|
|
1069
1130
|
if (res.status === 401 || res.status === 409) {
|
|
1070
1131
|
if (cred.source === "env") {
|
|
1071
1132
|
throw new PublishRefusal(
|
|
1072
|
-
res.status === 401 ?
|
|
1133
|
+
res.status === 401 ? ENV_TOKEN_REJECTED_MINT_AGAIN : (
|
|
1134
|
+
// The handle came from whoami moments ago, so a 409 means the bind changed in between
|
|
1135
|
+
// (a re-login on another machine after a GitHub rename). A fresh run looks it up again.
|
|
1136
|
+
"The server no longer accepts this handle for the YMMV_TOKEN account. Re-run the command."
|
|
1137
|
+
)
|
|
1073
1138
|
);
|
|
1074
1139
|
}
|
|
1075
1140
|
const was401 = res.status === 401;
|
|
@@ -1143,6 +1208,7 @@ async function fetchProfileJson(handle) {
|
|
|
1143
1208
|
return parseProfile(await res.json());
|
|
1144
1209
|
}
|
|
1145
1210
|
async function deleteProfile(cred) {
|
|
1211
|
+
assertVerified(cred);
|
|
1146
1212
|
const res = await safeFetch(
|
|
1147
1213
|
`${BASE}/api/v1/profile`,
|
|
1148
1214
|
{
|
|
@@ -1529,16 +1595,16 @@ function requireHandle(cred) {
|
|
|
1529
1595
|
if (cred.handle) return cred.handle;
|
|
1530
1596
|
console.error(
|
|
1531
1597
|
message(
|
|
1532
|
-
cred.source === "env" ? "
|
|
1598
|
+
cred.source === "env" ? "The account behind YMMV_TOKEN has no handle bound. Run `ymmv login` on an interactive machine, as the same GitHub account, to rebind it. If that GitHub username is a reserved word, rename on GitHub first." : "Your GitHub username is a reserved word, so no handle is bound. Rename on GitHub, then run `ymmv login` again."
|
|
1533
1599
|
)
|
|
1534
1600
|
);
|
|
1535
1601
|
process.exitCode = 1;
|
|
1536
1602
|
return null;
|
|
1537
1603
|
}
|
|
1538
|
-
function assertHandleUnchanged(existing, handle) {
|
|
1604
|
+
function assertHandleUnchanged(existing, cred, handle) {
|
|
1539
1605
|
if (existing && existing.handle.toLowerCase() !== handle.toLowerCase()) {
|
|
1540
1606
|
throw new Error(
|
|
1541
|
-
`This login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}". Run
|
|
1607
|
+
`This login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}". ${cred.source === "env" ? "Re-run the command." : "Run `ymmv login` to refresh, then retry."}`
|
|
1542
1608
|
);
|
|
1543
1609
|
}
|
|
1544
1610
|
}
|
|
@@ -1601,7 +1667,7 @@ async function publish(io) {
|
|
|
1601
1667
|
}
|
|
1602
1668
|
});
|
|
1603
1669
|
const existing = await fetchProfileJson(handle);
|
|
1604
|
-
assertHandleUnchanged(existing, handle);
|
|
1670
|
+
assertHandleUnchanged(existing, cred, handle);
|
|
1605
1671
|
const defaults = buildDefaults(existing, detected);
|
|
1606
1672
|
const carried = unknownEntries(existing);
|
|
1607
1673
|
const extras = existing?.extras ?? [];
|
|
@@ -1698,7 +1764,26 @@ async function view(handle) {
|
|
|
1698
1764
|
console.log(notFound(handle, c, BASE));
|
|
1699
1765
|
return;
|
|
1700
1766
|
}
|
|
1701
|
-
const
|
|
1767
|
+
const plainCard = (note, wrap = true) => {
|
|
1768
|
+
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
1769
|
+
if (note) {
|
|
1770
|
+
const codes = palette(c);
|
|
1771
|
+
console.error(message(`${codes.faint}${wrap ? `(${note})` : note}${codes.reset}`));
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
let cred = await loadCredential();
|
|
1775
|
+
if (cred?.source === "env") {
|
|
1776
|
+
try {
|
|
1777
|
+
cred = await verifyEnvCredential(cred);
|
|
1778
|
+
} catch (e) {
|
|
1779
|
+
plainCard(`No diff: ${displayError(e)}`, false);
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
if (cred.handle === null) {
|
|
1783
|
+
plainCard("no diff: the account behind YMMV_TOKEN has no handle bound");
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1702
1787
|
if (cred?.handle) {
|
|
1703
1788
|
let mineFailed = false;
|
|
1704
1789
|
const mine = await fetchProfileJson(cred.handle).catch(() => {
|
|
@@ -1712,24 +1797,23 @@ async function view(handle) {
|
|
|
1712
1797
|
return;
|
|
1713
1798
|
}
|
|
1714
1799
|
if (!mine) {
|
|
1715
|
-
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
1716
1800
|
if (mineFailed) {
|
|
1717
|
-
|
|
1718
|
-
console.error(message(`${codes.faint}(couldn't load your profile to diff)${codes.reset}`));
|
|
1801
|
+
plainCard("couldn't load your profile to diff");
|
|
1719
1802
|
} else {
|
|
1803
|
+
plainCard();
|
|
1720
1804
|
console.log(nudge(c));
|
|
1721
1805
|
}
|
|
1722
1806
|
return;
|
|
1723
1807
|
}
|
|
1724
1808
|
}
|
|
1725
|
-
|
|
1809
|
+
plainCard();
|
|
1726
1810
|
}
|
|
1727
1811
|
async function runSet(target) {
|
|
1728
1812
|
const cred = await ensureLogin();
|
|
1729
1813
|
const handle = requireHandle(cred);
|
|
1730
1814
|
if (!handle) return;
|
|
1731
1815
|
const existing = await fetchProfileJson(handle);
|
|
1732
|
-
assertHandleUnchanged(existing, handle);
|
|
1816
|
+
assertHandleUnchanged(existing, cred, handle);
|
|
1733
1817
|
const { entries, extras } = applySet(existing, target);
|
|
1734
1818
|
if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
|
|
1735
1819
|
console.error(
|
|
@@ -1749,7 +1833,7 @@ async function runUnset(target) {
|
|
|
1749
1833
|
const handle = requireHandle(cred);
|
|
1750
1834
|
if (!handle) return;
|
|
1751
1835
|
const existing = await fetchProfileJson(handle);
|
|
1752
|
-
assertHandleUnchanged(existing, handle);
|
|
1836
|
+
assertHandleUnchanged(existing, cred, handle);
|
|
1753
1837
|
if (!existing) {
|
|
1754
1838
|
console.log(message("No profile yet. Run `ymmv` to publish one."));
|
|
1755
1839
|
return;
|
|
@@ -1769,7 +1853,7 @@ async function runUnset(target) {
|
|
|
1769
1853
|
}
|
|
1770
1854
|
async function runDelete(io) {
|
|
1771
1855
|
const cred = await ensureLogin();
|
|
1772
|
-
const target = cred.
|
|
1856
|
+
const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : cred.source === "env" ? "the profile bound to YMMV_TOKEN" : "your profile";
|
|
1773
1857
|
if (!io.yes) {
|
|
1774
1858
|
if (!io.interactive || !io.prompter) {
|
|
1775
1859
|
console.error(
|