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.
- package/README.md +3 -1
- package/dist/cli.js +102 -33
- 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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
989
|
+
const minted = await mintYmmvToken(accessToken);
|
|
954
990
|
const replaced = await peekCredential();
|
|
955
991
|
try {
|
|
956
|
-
await saveToken(
|
|
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(
|
|
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
|
|
1010
|
-
if (
|
|
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
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
}
|