ymmv-cli 0.10.0 → 0.11.1
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 +21 -12
- package/dist/cli.js +279 -57
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,16 +69,21 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
|
|
|
69
69
|
|
|
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
|
-
Worker must be deployed with or after this CLI release: `ymmv login` requires
|
|
73
|
-
login response carries and
|
|
72
|
+
Worker must be deployed with or after this CLI release: `ymmv login` requires every field the
|
|
73
|
+
login response carries (the account id, and on a re-login the server's answer to retiring the
|
|
74
|
+
previous token) and refuses (revoking what it minted) otherwise, `YMMV_TOKEN` needs the
|
|
75
|
+
server's account lookup, and `ymmv`, `ymmv set`, and `ymmv unset` need the profile lookup they
|
|
76
|
+
read from before writing.
|
|
74
77
|
- `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
|
|
75
78
|
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
|
-
`
|
|
79
|
+
`ymmv login` / `ymmv logout` keep acting on the stored login. The CLI asks the server which
|
|
80
|
+
account the token belongs to, so every command, including the you-side of a `ymmv <handle>`
|
|
81
|
+
diff, runs as that account. The token is sent to the server `YMMV_API` selects, so set the
|
|
82
|
+
two together.
|
|
83
|
+
- `YMMV_HANDLE` is optional. When set, it must name the GitHub username `YMMV_TOKEN` belongs
|
|
84
|
+
to: if the server reports a different account, `ymmv -y`, `ymmv set`/`unset`, and
|
|
85
|
+
`ymmv delete` refuse before sending anything, and `ymmv <handle>` shows the profile without
|
|
86
|
+
a diff. Ignored without `YMMV_TOKEN`.
|
|
82
87
|
- `YMMV_NO_UPDATE_CHECK` disables the startup check for newer releases (the ecosystem-standard
|
|
83
88
|
`NO_UPDATE_NOTIFIER` works too). The check is also off automatically under `CI`, in pipes,
|
|
84
89
|
and in dev builds; it never blocks or fails a command.
|
|
@@ -91,7 +96,8 @@ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full c
|
|
|
91
96
|
2. Copy the `token` value from the token file:
|
|
92
97
|
`~/.config/ymmv/token.json` (Linux), `~/Library/Preferences/ymmv/token.json` (macOS),
|
|
93
98
|
`%APPDATA%\ymmv\Config\token.json` (Windows).
|
|
94
|
-
3. Set it as a CI secret named `YMMV_TOKEN
|
|
99
|
+
3. Set it as a CI secret named `YMMV_TOKEN`. Setting `YMMV_HANDLE` to your GitHub username is
|
|
100
|
+
optional, and makes the job fail if the secret ever holds another account's token.
|
|
95
101
|
4. Run `npx ymmv-cli@latest -y` in the job.
|
|
96
102
|
|
|
97
103
|
Two things to know:
|
|
@@ -100,9 +106,12 @@ Two things to know:
|
|
|
100
106
|
runs on. Values you already published always win, but curated keys you have never set get the
|
|
101
107
|
CI runner's detected values (its OS, shell, and so on). For targeted updates from CI, prefer
|
|
102
108
|
`ymmv set <key> <value>`.
|
|
103
|
-
- A rejected or revoked `YMMV_TOKEN` fails
|
|
104
|
-
to an interactive login, and the stored login
|
|
105
|
-
|
|
109
|
+
- A rejected or revoked `YMMV_TOKEN` fails `ymmv -y`, `ymmv set`/`unset`, and `ymmv delete` with
|
|
110
|
+
an error naming the variable; nothing falls back to an interactive login, and the stored login
|
|
111
|
+
file on the runner (if any) is left untouched. `ymmv <handle>` still shows the profile, with the
|
|
112
|
+
reason there is no diff on stderr, and exits 0.
|
|
113
|
+
`ymmv delete` acts on the account the token is bound to and names that account's page when
|
|
114
|
+
it asks for confirmation.
|
|
106
115
|
|
|
107
116
|
## License
|
|
108
117
|
|
package/dist/cli.js
CHANGED
|
@@ -257,6 +257,34 @@ function diff(mine, theirs) {
|
|
|
257
257
|
};
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
// ../shared/dist/display-url.js
|
|
261
|
+
var PARSER_STRIPS = /[\t\n\r]/g;
|
|
262
|
+
var HTTP_RE = /^https?:[/\\]*([^/?#\\]*)([/?#\\][\s\S]*)?$/i;
|
|
263
|
+
function trimC0(s) {
|
|
264
|
+
let start = 0;
|
|
265
|
+
let end = s.length;
|
|
266
|
+
while (start < end && s.charCodeAt(start) <= 32)
|
|
267
|
+
start++;
|
|
268
|
+
while (end > start && s.charCodeAt(end - 1) <= 32)
|
|
269
|
+
end--;
|
|
270
|
+
return s.slice(start, end);
|
|
271
|
+
}
|
|
272
|
+
function displayUrl(value) {
|
|
273
|
+
const raw = value.trim();
|
|
274
|
+
const input = trimC0(raw.replace(PARSER_STRIPS, ""));
|
|
275
|
+
const m = HTTP_RE.exec(input);
|
|
276
|
+
if (!m)
|
|
277
|
+
return raw;
|
|
278
|
+
let url;
|
|
279
|
+
try {
|
|
280
|
+
url = new URL(input);
|
|
281
|
+
} catch {
|
|
282
|
+
return raw;
|
|
283
|
+
}
|
|
284
|
+
const prefix = url.protocol === "http:" ? "http://" : "";
|
|
285
|
+
return `${prefix}${url.host}${m[2] ?? ""}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
260
288
|
// ../shared/dist/github.js
|
|
261
289
|
var GITHUB_CLIENT_ID = "Ov23liMoD29eizQcN1KZ";
|
|
262
290
|
function isGithubId(x) {
|
|
@@ -410,12 +438,10 @@ function colorEnabled() {
|
|
|
410
438
|
}
|
|
411
439
|
var OSC = `${ESC}]`;
|
|
412
440
|
var ST = `${ESC}\\`;
|
|
413
|
-
function displayUrl(value) {
|
|
414
|
-
return value.trim().replace(/^https:\/\/(?=.)/i, "");
|
|
415
|
-
}
|
|
416
441
|
var HTTP_URL_RE = /^https?:\/\/\S+$/i;
|
|
417
442
|
function isHttpUrl(value) {
|
|
418
|
-
|
|
443
|
+
const t = value.trim();
|
|
444
|
+
return HTTP_URL_RE.test(t) && URL.canParse(t);
|
|
419
445
|
}
|
|
420
446
|
var NO_OSC8_TERMS = /* @__PURE__ */ new Set(["linux", "dumb"]);
|
|
421
447
|
function link(url, color, term = process.env.TERM, label) {
|
|
@@ -684,19 +710,34 @@ async function bodyJson(res) {
|
|
|
684
710
|
return null;
|
|
685
711
|
}
|
|
686
712
|
}
|
|
713
|
+
var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (invalid or revoked).";
|
|
714
|
+
var ENV_TOKEN_REJECTED_MINT_AGAIN = `${ENV_TOKEN_REJECTED} Mint a new one with \`ymmv login\` on an interactive machine and update YMMV_TOKEN.`;
|
|
715
|
+
function parseIdentity(data) {
|
|
716
|
+
if (typeof data !== "object" || data === null) return null;
|
|
717
|
+
const { handle: rawHandle, github_id: id } = data;
|
|
718
|
+
const handle = rawHandle === null ? null : typeof rawHandle === "string" ? sanitizeValue(rawHandle) : void 0;
|
|
719
|
+
if (handle === void 0 || handle !== null && !isValidHandle(handle) || !isGithubId(id)) {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
return { github_id: id, handle };
|
|
723
|
+
}
|
|
724
|
+
function serverBehindHint() {
|
|
725
|
+
return process.env.YMMV_API ? "Point YMMV_API at an up-to-date server." : "The server is behind this CLI release; try again later.";
|
|
726
|
+
}
|
|
687
727
|
var MintRejected = class extends Error {
|
|
688
728
|
constructor(msg) {
|
|
689
729
|
super(msg);
|
|
690
730
|
this.name = "MintRejected";
|
|
691
731
|
}
|
|
692
732
|
};
|
|
693
|
-
async function mintYmmvToken(accessToken) {
|
|
733
|
+
async function mintYmmvToken(accessToken, revoke) {
|
|
694
734
|
const res = await safeFetch(
|
|
695
735
|
`${BASE}/api/v1/auth/token`,
|
|
696
736
|
{
|
|
697
737
|
method: "POST",
|
|
698
738
|
headers: { "content-type": "application/json" },
|
|
699
|
-
|
|
739
|
+
// JSON.stringify drops an undefined `revoke`: no key on the wire when there is nothing to retire.
|
|
740
|
+
body: JSON.stringify({ access_token: accessToken, revoke }),
|
|
700
741
|
// Never follow a redirect: a 30x must fail (the existing `!res.ok` guard rejects the resulting
|
|
701
742
|
// opaqueredirect), not re-POST the GitHub access_token to the redirect target or read a
|
|
702
743
|
// redirected 200 as a successful mint. Mirrors publish/delete in api.ts.
|
|
@@ -731,22 +772,57 @@ async function mintYmmvToken(accessToken) {
|
|
|
731
772
|
throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
|
|
732
773
|
}
|
|
733
774
|
const data = await bodyJson(res);
|
|
734
|
-
const unexpected = `Unexpected response from ${BASE}. Nothing was saved; run
|
|
775
|
+
const unexpected = `Unexpected response from ${BASE}. Nothing was saved` + (revoke === void 0 ? "" : " (the previous login on this machine may have been signed out)") + "; run `ymmv login` again.";
|
|
735
776
|
if (!data) throw new Error(unexpected);
|
|
736
777
|
const token = typeof data.token === "string" && data.token.length > 0 ? data.token : null;
|
|
737
|
-
const
|
|
738
|
-
|
|
778
|
+
const identity = parseIdentity(data);
|
|
779
|
+
const revoked = typeof data.revoked === "boolean" ? data.revoked : void 0;
|
|
780
|
+
const behind = token !== null && identity !== null && revoke !== void 0 && revoked === void 0 ? `${BASE} did not retire the previous login. ${serverBehindHint()} Nothing was saved; to log in anyway, run \`ymmv logout\` first.` : null;
|
|
781
|
+
if (token === null || identity === null || behind !== null) {
|
|
739
782
|
let revokeFailed = false;
|
|
740
783
|
if (token !== null) {
|
|
741
784
|
await revokeYmmvToken(token, AbortSignal.timeout(REVOKE_CAP_MS)).catch(() => {
|
|
742
785
|
revokeFailed = true;
|
|
743
786
|
});
|
|
744
787
|
}
|
|
788
|
+
const refusal = behind ?? unexpected;
|
|
745
789
|
throw new MintRejected(
|
|
746
|
-
revokeFailed ? `${
|
|
790
|
+
revokeFailed ? `${refusal} The login the server minted could not be revoked.` : refusal
|
|
747
791
|
);
|
|
748
792
|
}
|
|
749
|
-
return { token,
|
|
793
|
+
return { token, ...identity };
|
|
794
|
+
}
|
|
795
|
+
function missingRouteError(missing) {
|
|
796
|
+
return new Error(`${BASE} has no ${missing}. ${serverBehindHint()}`);
|
|
797
|
+
}
|
|
798
|
+
async function fetchWhoami(token) {
|
|
799
|
+
const res = await safeFetch(
|
|
800
|
+
`${BASE}/api/v1/auth/whoami`,
|
|
801
|
+
{
|
|
802
|
+
headers: { authorization: `Bearer ${token}` },
|
|
803
|
+
// Never follow a redirect: the bearer must not travel to a redirect target, and a 30x→200
|
|
804
|
+
// must not read as a verified identity. Same guard as mint, logout, publish, and delete.
|
|
805
|
+
redirect: "manual"
|
|
806
|
+
},
|
|
807
|
+
BASE
|
|
808
|
+
);
|
|
809
|
+
if (!res.ok) {
|
|
810
|
+
if (res.status === 401) throw new Error(ENV_TOKEN_REJECTED_MINT_AGAIN);
|
|
811
|
+
if (res.status === 404) {
|
|
812
|
+
throw missingRouteError("identity lookup for YMMV_TOKEN");
|
|
813
|
+
}
|
|
814
|
+
throw new Error(
|
|
815
|
+
withRetryHint(
|
|
816
|
+
await serverMessage(res) ?? (res.status === 429 ? "rate limited, too many requests" : `The server couldn't look up your token (${res.status}). Try again shortly.`),
|
|
817
|
+
res
|
|
818
|
+
)
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
const identity = parseIdentity(await bodyJson(res));
|
|
822
|
+
if (!identity) {
|
|
823
|
+
throw new Error(`Unexpected response from ${BASE}. Check YMMV_API, or try again shortly.`);
|
|
824
|
+
}
|
|
825
|
+
return identity;
|
|
750
826
|
}
|
|
751
827
|
async function revokeYmmvToken(token, signal) {
|
|
752
828
|
const res = await safeFetch(
|
|
@@ -954,6 +1030,9 @@ async function pollForToken(dc, deps = {}) {
|
|
|
954
1030
|
}
|
|
955
1031
|
throw new Error("Device code expired. Run `ymmv login` again.");
|
|
956
1032
|
}
|
|
1033
|
+
function retirable(cred) {
|
|
1034
|
+
return cred != null && cred.base === BASE && cred.token.trim() !== "";
|
|
1035
|
+
}
|
|
957
1036
|
async function login(deps = {}) {
|
|
958
1037
|
if (!process.stdin.isTTY) {
|
|
959
1038
|
throw new Error(
|
|
@@ -986,18 +1065,23 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
986
1065
|
)
|
|
987
1066
|
);
|
|
988
1067
|
const accessToken = await pollForToken(dc, deps);
|
|
989
|
-
const
|
|
990
|
-
const
|
|
1068
|
+
const before = await peekCredential();
|
|
1069
|
+
const revoke = retirable(before) ? before.token : void 0;
|
|
1070
|
+
const minted = await mintYmmvToken(accessToken, revoke);
|
|
1071
|
+
const after = await peekCredential();
|
|
991
1072
|
try {
|
|
992
1073
|
await saveToken(minted);
|
|
993
1074
|
} catch (e) {
|
|
994
1075
|
await revokeYmmvToken(minted.token).catch(() => {
|
|
1076
|
+
console.error(
|
|
1077
|
+
message(`${c.faint}(the login the server minted could not be revoked)${c.reset}`)
|
|
1078
|
+
);
|
|
995
1079
|
});
|
|
996
1080
|
throw e;
|
|
997
1081
|
}
|
|
998
|
-
if (
|
|
1082
|
+
if (retirable(after) && after.token !== revoke && after.token !== minted.token) {
|
|
999
1083
|
try {
|
|
1000
|
-
await revokeYmmvToken(
|
|
1084
|
+
await revokeYmmvToken(after.token);
|
|
1001
1085
|
} catch {
|
|
1002
1086
|
console.error(message(`${c.faint}(couldn't revoke the previous session's token)${c.reset}`));
|
|
1003
1087
|
}
|
|
@@ -1016,8 +1100,20 @@ var PublishRefusal = class extends Error {
|
|
|
1016
1100
|
this.name = "PublishRefusal";
|
|
1017
1101
|
}
|
|
1018
1102
|
};
|
|
1019
|
-
var
|
|
1103
|
+
var ProfileChanged = class extends PublishRefusal {
|
|
1104
|
+
constructor() {
|
|
1105
|
+
super("Your profile changed since this command read it. Re-run the command.");
|
|
1106
|
+
this.name = "ProfileChanged";
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1020
1109
|
var NOT_PERSISTED = "Login did not persist a token. Run `ymmv login`.";
|
|
1110
|
+
function assertVerified(cred) {
|
|
1111
|
+
if (cred.source === "env" && cred.github_id === null) {
|
|
1112
|
+
throw new PublishRefusal(
|
|
1113
|
+
"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."
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1021
1117
|
async function rateLimitMessage(res) {
|
|
1022
1118
|
return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
|
|
1023
1119
|
}
|
|
@@ -1032,20 +1128,35 @@ async function loginOrRefuse() {
|
|
|
1032
1128
|
if (!fresh) throw new PublishRefusal(NOT_PERSISTED);
|
|
1033
1129
|
return fresh;
|
|
1034
1130
|
}
|
|
1131
|
+
async function verifyEnvCredential(cred) {
|
|
1132
|
+
const identity = await fetchWhoami(cred.token);
|
|
1133
|
+
const claimed = cred.handle;
|
|
1134
|
+
if (claimed !== null && claimed.toLowerCase() !== identity.handle?.toLowerCase()) {
|
|
1135
|
+
const shown = `YMMV_HANDLE is "${sanitizeValue(claimed)}"`;
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
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.`
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
return { ...cred, handle: identity.handle, github_id: identity.github_id };
|
|
1141
|
+
}
|
|
1035
1142
|
async function ensureLogin() {
|
|
1036
1143
|
const existing = await loadCredential();
|
|
1037
|
-
if (existing) return existing;
|
|
1144
|
+
if (existing) return existing.source === "env" ? verifyEnvCredential(existing) : existing;
|
|
1038
1145
|
await login();
|
|
1039
1146
|
const fresh = await loadCredential();
|
|
1040
1147
|
if (!fresh) throw new Error(NOT_PERSISTED);
|
|
1041
1148
|
return fresh;
|
|
1042
1149
|
}
|
|
1043
|
-
async function publishProfile(profile, expected) {
|
|
1150
|
+
async function publishProfile(profile, expected, opts = {}) {
|
|
1044
1151
|
const send = (c) => safeFetch(
|
|
1045
1152
|
`${BASE}/api/v1/profile`,
|
|
1046
1153
|
{
|
|
1047
1154
|
method: "POST",
|
|
1048
|
-
headers: {
|
|
1155
|
+
headers: {
|
|
1156
|
+
"content-type": "application/json",
|
|
1157
|
+
authorization: `Bearer ${c.token}`,
|
|
1158
|
+
...opts.ifMatch ? { "if-match": opts.ifMatch } : {}
|
|
1159
|
+
},
|
|
1049
1160
|
// Send the login-bound handle, never a caller-guessed one — the official client never claims
|
|
1050
1161
|
// a handle it doesn't own.
|
|
1051
1162
|
body: JSON.stringify({ ...profile, handle: c.handle ?? profile.handle }),
|
|
@@ -1054,11 +1165,13 @@ async function publishProfile(profile, expected) {
|
|
|
1054
1165
|
},
|
|
1055
1166
|
BASE
|
|
1056
1167
|
);
|
|
1057
|
-
|
|
1168
|
+
assertVerified(expected);
|
|
1169
|
+
let cred = expected.source === "env" ? expected : await loadCredential();
|
|
1058
1170
|
if (!cred) {
|
|
1059
1171
|
console.log(message("Not logged in. Logging in to publish."));
|
|
1060
1172
|
cred = await loginOrRefuse();
|
|
1061
1173
|
}
|
|
1174
|
+
assertVerified(cred);
|
|
1062
1175
|
const idDrifted = expected.source === "file" && (expected.github_id === null ? cred.github_id !== null : cred.github_id !== expected.github_id);
|
|
1063
1176
|
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase() || idDrifted) {
|
|
1064
1177
|
throw new PublishRefusal(
|
|
@@ -1069,7 +1182,11 @@ async function publishProfile(profile, expected) {
|
|
|
1069
1182
|
if (res.status === 401 || res.status === 409) {
|
|
1070
1183
|
if (cred.source === "env") {
|
|
1071
1184
|
throw new PublishRefusal(
|
|
1072
|
-
res.status === 401 ?
|
|
1185
|
+
res.status === 401 ? ENV_TOKEN_REJECTED_MINT_AGAIN : (
|
|
1186
|
+
// The handle came from whoami moments ago, so a 409 means the bind changed in between
|
|
1187
|
+
// (a re-login on another machine after a GitHub rename). A fresh run looks it up again.
|
|
1188
|
+
"The server no longer accepts this handle for the YMMV_TOKEN account. Re-run the command."
|
|
1189
|
+
)
|
|
1073
1190
|
);
|
|
1074
1191
|
}
|
|
1075
1192
|
const was401 = res.status === 401;
|
|
@@ -1117,6 +1234,7 @@ async function publishProfile(profile, expected) {
|
|
|
1117
1234
|
);
|
|
1118
1235
|
}
|
|
1119
1236
|
}
|
|
1237
|
+
if (res.status === 412) throw new ProfileChanged();
|
|
1120
1238
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
1121
1239
|
if (!res.ok) {
|
|
1122
1240
|
const raw = await wireBody(res);
|
|
@@ -1142,7 +1260,42 @@ async function fetchProfileJson(handle) {
|
|
|
1142
1260
|
}
|
|
1143
1261
|
return parseProfile(await res.json());
|
|
1144
1262
|
}
|
|
1263
|
+
var HEADER_SAFE_TAG = /^[!#-~]{1,128}$/;
|
|
1264
|
+
async function fetchOwnProfile(cred) {
|
|
1265
|
+
assertVerified(cred);
|
|
1266
|
+
const res = await safeFetch(
|
|
1267
|
+
`${BASE}/api/v1/profile`,
|
|
1268
|
+
{
|
|
1269
|
+
headers: { authorization: `Bearer ${cred.token}` },
|
|
1270
|
+
// Never follow a redirect: the bearer must not travel to a redirect target, and a 30x→200
|
|
1271
|
+
// must not read as a profile. Same guard as whoami, mint, logout, publish, and delete.
|
|
1272
|
+
redirect: "manual"
|
|
1273
|
+
},
|
|
1274
|
+
BASE
|
|
1275
|
+
);
|
|
1276
|
+
if (res.status === 401) {
|
|
1277
|
+
throw new Error(
|
|
1278
|
+
cred.source === "env" ? ENV_TOKEN_REJECTED_MINT_AGAIN : "Session expired. Run `ymmv login`, then re-run the command."
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
if (res.status === 404) {
|
|
1282
|
+
const { slug } = wireErrorBody(await wireBody(res));
|
|
1283
|
+
if (slug === "not_found") return null;
|
|
1284
|
+
throw missingRouteError("profile lookup");
|
|
1285
|
+
}
|
|
1286
|
+
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
1287
|
+
if (!res.ok) {
|
|
1288
|
+
const raw = await wireBody(res);
|
|
1289
|
+
throw new Error(withRetryHint(`fetch failed: ${res.status} ${wireText(raw)}`, res));
|
|
1290
|
+
}
|
|
1291
|
+
const profile = parseProfile(await res.json());
|
|
1292
|
+
if (!HEADER_SAFE_TAG.test(profile.updated_at)) {
|
|
1293
|
+
throw new Error(`Unexpected response from ${BASE}. Check YMMV_API, or try again shortly.`);
|
|
1294
|
+
}
|
|
1295
|
+
return { profile, etag: `"${profile.updated_at}"` };
|
|
1296
|
+
}
|
|
1145
1297
|
async function deleteProfile(cred) {
|
|
1298
|
+
assertVerified(cred);
|
|
1146
1299
|
const res = await safeFetch(
|
|
1147
1300
|
`${BASE}/api/v1/profile`,
|
|
1148
1301
|
{
|
|
@@ -1529,16 +1682,16 @@ function requireHandle(cred) {
|
|
|
1529
1682
|
if (cred.handle) return cred.handle;
|
|
1530
1683
|
console.error(
|
|
1531
1684
|
message(
|
|
1532
|
-
cred.source === "env" ? "
|
|
1685
|
+
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
1686
|
)
|
|
1534
1687
|
);
|
|
1535
1688
|
process.exitCode = 1;
|
|
1536
1689
|
return null;
|
|
1537
1690
|
}
|
|
1538
|
-
function assertHandleUnchanged(existing, handle) {
|
|
1691
|
+
function assertHandleUnchanged(existing, cred, handle) {
|
|
1539
1692
|
if (existing && existing.handle.toLowerCase() !== handle.toLowerCase()) {
|
|
1540
1693
|
throw new Error(
|
|
1541
|
-
`This login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}". Run
|
|
1694
|
+
`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
1695
|
);
|
|
1543
1696
|
}
|
|
1544
1697
|
}
|
|
@@ -1600,11 +1753,13 @@ async function publish(io) {
|
|
|
1600
1753
|
return readFileSync(p, "utf8");
|
|
1601
1754
|
}
|
|
1602
1755
|
});
|
|
1603
|
-
const
|
|
1604
|
-
|
|
1756
|
+
const own = await fetchOwnProfile(cred);
|
|
1757
|
+
const existing = own?.profile ?? null;
|
|
1758
|
+
assertHandleUnchanged(existing, cred, handle);
|
|
1605
1759
|
const defaults = buildDefaults(existing, detected);
|
|
1606
|
-
|
|
1607
|
-
|
|
1760
|
+
let carried = unknownEntries(existing);
|
|
1761
|
+
let extras = existing?.extras ?? [];
|
|
1762
|
+
let ifMatch = own?.etag;
|
|
1608
1763
|
const color = colorEnabled();
|
|
1609
1764
|
const site = displayUrl(BASE);
|
|
1610
1765
|
const showCard = (entries) => {
|
|
@@ -1632,6 +1787,15 @@ async function publish(io) {
|
|
|
1632
1787
|
};
|
|
1633
1788
|
let values = defaults;
|
|
1634
1789
|
const assemble = () => [...entriesFromMap(values), ...carried];
|
|
1790
|
+
const edits = /* @__PURE__ */ new Map();
|
|
1791
|
+
const prompt = async (prompter) => {
|
|
1792
|
+
const before = values;
|
|
1793
|
+
values = await promptEntries(values, prompter);
|
|
1794
|
+
for (const key of CURATED_KEYS) {
|
|
1795
|
+
const after = values.get(key);
|
|
1796
|
+
if (after !== before.get(key)) edits.set(key, after);
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1635
1799
|
if (!io.interactive || !io.prompter || io.yes) {
|
|
1636
1800
|
const over = [...values].find(([, v]) => v.length > MAX_VALUE);
|
|
1637
1801
|
if (over) {
|
|
@@ -1645,11 +1809,14 @@ async function publish(io) {
|
|
|
1645
1809
|
}
|
|
1646
1810
|
const entries = assemble();
|
|
1647
1811
|
showCard(entries);
|
|
1648
|
-
printPublished(
|
|
1812
|
+
printPublished(
|
|
1813
|
+
await publishProfile(newProfile(handle, entries, extras), cred, { ifMatch }),
|
|
1814
|
+
color
|
|
1815
|
+
);
|
|
1649
1816
|
return;
|
|
1650
1817
|
}
|
|
1651
1818
|
try {
|
|
1652
|
-
if (!existing)
|
|
1819
|
+
if (!existing) await prompt(io.prompter);
|
|
1653
1820
|
for (; ; ) {
|
|
1654
1821
|
const entries = assemble();
|
|
1655
1822
|
showCard(entries);
|
|
@@ -1661,9 +1828,37 @@ async function publish(io) {
|
|
|
1661
1828
|
);
|
|
1662
1829
|
if (ans === "y") {
|
|
1663
1830
|
try {
|
|
1664
|
-
printPublished(
|
|
1831
|
+
printPublished(
|
|
1832
|
+
await publishProfile(newProfile(handle, entries, extras), cred, { ifMatch }),
|
|
1833
|
+
color
|
|
1834
|
+
);
|
|
1665
1835
|
return;
|
|
1666
1836
|
} catch (e) {
|
|
1837
|
+
if (e instanceof ProfileChanged) {
|
|
1838
|
+
console.error(
|
|
1839
|
+
message(
|
|
1840
|
+
"Your profile changed since this command read it. Reloaded the current version; your answers are kept."
|
|
1841
|
+
)
|
|
1842
|
+
);
|
|
1843
|
+
const liveCred = cred.source === "env" ? cred : await loadCredential() ?? cred;
|
|
1844
|
+
const fresh = await fetchOwnProfile(liveCred);
|
|
1845
|
+
if (!fresh) {
|
|
1846
|
+
throw new PublishRefusal(
|
|
1847
|
+
"Your profile was deleted since this command read it. Nothing was published. Run `ymmv` again to recreate it."
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
assertHandleUnchanged(fresh.profile, cred, handle);
|
|
1851
|
+
const rebased = buildDefaults(fresh.profile, detected);
|
|
1852
|
+
for (const [key, value] of edits) {
|
|
1853
|
+
if (value === void 0) rebased.delete(key);
|
|
1854
|
+
else rebased.set(key, value);
|
|
1855
|
+
}
|
|
1856
|
+
values = rebased;
|
|
1857
|
+
carried = unknownEntries(fresh.profile);
|
|
1858
|
+
extras = fresh.profile.extras;
|
|
1859
|
+
ifMatch = fresh.etag;
|
|
1860
|
+
continue;
|
|
1861
|
+
}
|
|
1667
1862
|
if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
|
|
1668
1863
|
const ambiguous = e instanceof NetworkError || isTimeoutError(e);
|
|
1669
1864
|
console.error(
|
|
@@ -1679,7 +1874,7 @@ ${ambiguous ? "The publish may not have completed. Your answers are kept." : "No
|
|
|
1679
1874
|
console.log(message("Aborted. Nothing published."));
|
|
1680
1875
|
return;
|
|
1681
1876
|
}
|
|
1682
|
-
|
|
1877
|
+
await prompt(io.prompter);
|
|
1683
1878
|
}
|
|
1684
1879
|
} catch (e) {
|
|
1685
1880
|
if (e instanceof PromptAborted) {
|
|
@@ -1698,7 +1893,26 @@ async function view(handle) {
|
|
|
1698
1893
|
console.log(notFound(handle, c, BASE));
|
|
1699
1894
|
return;
|
|
1700
1895
|
}
|
|
1701
|
-
const
|
|
1896
|
+
const plainCard = (note, wrap = true) => {
|
|
1897
|
+
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
1898
|
+
if (note) {
|
|
1899
|
+
const codes = palette(c);
|
|
1900
|
+
console.error(message(`${codes.faint}${wrap ? `(${note})` : note}${codes.reset}`));
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
let cred = await loadCredential();
|
|
1904
|
+
if (cred?.source === "env") {
|
|
1905
|
+
try {
|
|
1906
|
+
cred = await verifyEnvCredential(cred);
|
|
1907
|
+
} catch (e) {
|
|
1908
|
+
plainCard(`No diff: ${displayError(e)}`, false);
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
if (cred.handle === null) {
|
|
1912
|
+
plainCard("no diff: the account behind YMMV_TOKEN has no handle bound");
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1702
1916
|
if (cred?.handle) {
|
|
1703
1917
|
let mineFailed = false;
|
|
1704
1918
|
const mine = await fetchProfileJson(cred.handle).catch(() => {
|
|
@@ -1712,24 +1926,24 @@ async function view(handle) {
|
|
|
1712
1926
|
return;
|
|
1713
1927
|
}
|
|
1714
1928
|
if (!mine) {
|
|
1715
|
-
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
1716
1929
|
if (mineFailed) {
|
|
1717
|
-
|
|
1718
|
-
console.error(message(`${codes.faint}(couldn't load your profile to diff)${codes.reset}`));
|
|
1930
|
+
plainCard("couldn't load your profile to diff");
|
|
1719
1931
|
} else {
|
|
1932
|
+
plainCard();
|
|
1720
1933
|
console.log(nudge(c));
|
|
1721
1934
|
}
|
|
1722
1935
|
return;
|
|
1723
1936
|
}
|
|
1724
1937
|
}
|
|
1725
|
-
|
|
1938
|
+
plainCard();
|
|
1726
1939
|
}
|
|
1727
1940
|
async function runSet(target) {
|
|
1728
1941
|
const cred = await ensureLogin();
|
|
1729
1942
|
const handle = requireHandle(cred);
|
|
1730
1943
|
if (!handle) return;
|
|
1731
|
-
const
|
|
1732
|
-
|
|
1944
|
+
const own = await fetchOwnProfile(cred);
|
|
1945
|
+
const existing = own?.profile ?? null;
|
|
1946
|
+
assertHandleUnchanged(existing, cred, handle);
|
|
1733
1947
|
const { entries, extras } = applySet(existing, target);
|
|
1734
1948
|
if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
|
|
1735
1949
|
console.error(
|
|
@@ -1740,36 +1954,47 @@ async function runSet(target) {
|
|
|
1740
1954
|
process.exitCode = 1;
|
|
1741
1955
|
return;
|
|
1742
1956
|
}
|
|
1743
|
-
const res = await publishProfile(newProfile(handle, entries, extras), cred
|
|
1957
|
+
const res = await publishProfile(newProfile(handle, entries, extras), cred, {
|
|
1958
|
+
ifMatch: own?.etag
|
|
1959
|
+
});
|
|
1744
1960
|
const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
|
|
1745
1961
|
console.log(message(`${line}${pagePointer(res.handle)}`));
|
|
1746
1962
|
}
|
|
1963
|
+
var PASTE_SAFE_LABEL = /^[\p{L}\p{N} _.+/:-]+$/u;
|
|
1964
|
+
function extraHint(existing, label) {
|
|
1965
|
+
const eq = label.indexOf("=");
|
|
1966
|
+
const head = eq > 0 ? label.slice(0, eq).trim() : "";
|
|
1967
|
+
if (!head || !applyUnset(existing, { kind: "extra", label: head }).removed) return "";
|
|
1968
|
+
const shown = PASTE_SAFE_LABEL.test(head) ? head : "Label";
|
|
1969
|
+
return `
|
|
1970
|
+
(unset takes just the label: ymmv unset --extra "${shown}")`;
|
|
1971
|
+
}
|
|
1747
1972
|
async function runUnset(target) {
|
|
1748
1973
|
const cred = await ensureLogin();
|
|
1749
1974
|
const handle = requireHandle(cred);
|
|
1750
1975
|
if (!handle) return;
|
|
1751
|
-
const
|
|
1752
|
-
|
|
1976
|
+
const own = await fetchOwnProfile(cred);
|
|
1977
|
+
const existing = own?.profile ?? null;
|
|
1978
|
+
assertHandleUnchanged(existing, cred, handle);
|
|
1753
1979
|
if (!existing) {
|
|
1754
1980
|
console.log(message("No profile yet. Run `ymmv` to publish one."));
|
|
1755
1981
|
return;
|
|
1756
1982
|
}
|
|
1757
1983
|
const { entries, extras, removed } = applyUnset(existing, target);
|
|
1758
1984
|
if (!removed) {
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${target.label}".`
|
|
1762
|
-
)
|
|
1763
|
-
);
|
|
1985
|
+
const line2 = target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${sanitizeValue(target.label)}".${extraHint(existing, target.label)}`;
|
|
1986
|
+
console.log(message(line2));
|
|
1764
1987
|
return;
|
|
1765
1988
|
}
|
|
1766
|
-
const res = await publishProfile(newProfile(handle, entries, extras), cred
|
|
1989
|
+
const res = await publishProfile(newProfile(handle, entries, extras), cred, {
|
|
1990
|
+
ifMatch: own?.etag
|
|
1991
|
+
});
|
|
1767
1992
|
const line = target.kind === "curated" ? `Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").` : `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`;
|
|
1768
1993
|
console.log(message(`${line}${pagePointer(res.handle)}`));
|
|
1769
1994
|
}
|
|
1770
1995
|
async function runDelete(io) {
|
|
1771
1996
|
const cred = await ensureLogin();
|
|
1772
|
-
const target = cred.
|
|
1997
|
+
const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : cred.source === "env" ? "the profile bound to YMMV_TOKEN" : "your profile";
|
|
1773
1998
|
if (!io.yes) {
|
|
1774
1999
|
if (!io.interactive || !io.prompter) {
|
|
1775
2000
|
console.error(
|
|
@@ -1863,12 +2088,7 @@ function parseUnset(rest) {
|
|
|
1863
2088
|
if (head === "--extra" || head === "-e") {
|
|
1864
2089
|
const label = rest.slice(1).join(" ").trim();
|
|
1865
2090
|
if (!label) return { kind: "error", message: `usage: ${UNSET_EXTRA}` };
|
|
1866
|
-
if (label.
|
|
1867
|
-
return {
|
|
1868
|
-
kind: "error",
|
|
1869
|
-
message: 'unset takes just the label: ymmv unset --extra "Keyboard"'
|
|
1870
|
-
};
|
|
1871
|
-
}
|
|
2091
|
+
if (label.length > MAX_LABEL) return labelCapError(label);
|
|
1872
2092
|
return { kind: "unset", target: { kind: "extra", label } };
|
|
1873
2093
|
}
|
|
1874
2094
|
if (!head) return { kind: "error", message: UNSET_USAGE };
|
|
@@ -2233,7 +2453,9 @@ ${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser,
|
|
|
2233
2453
|
font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
|
|
2234
2454
|
async function logout() {
|
|
2235
2455
|
const stored = await loadToken();
|
|
2236
|
-
|
|
2456
|
+
const leftover = stored ? null : await peekCredential();
|
|
2457
|
+
const token = stored?.token ?? (retirable(leftover) ? leftover.token : null);
|
|
2458
|
+
if (token === null) {
|
|
2237
2459
|
const otherBase = await peekBase();
|
|
2238
2460
|
console.log(
|
|
2239
2461
|
message(
|
|
@@ -2244,7 +2466,7 @@ async function logout() {
|
|
|
2244
2466
|
}
|
|
2245
2467
|
let revoked;
|
|
2246
2468
|
try {
|
|
2247
|
-
revoked = await revokeYmmvToken(
|
|
2469
|
+
revoked = await revokeYmmvToken(token);
|
|
2248
2470
|
} catch (e) {
|
|
2249
2471
|
console.error(
|
|
2250
2472
|
message(
|