ymmv-cli 0.11.0 → 0.11.2
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 +7 -5
- package/dist/cli.js +279 -79
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,8 +58,8 @@ via npm Trusted Publishing, with provenance.
|
|
|
58
58
|
the commands to run by hand)
|
|
59
59
|
- `ymmv version` prints the CLI version (and notes a newer release when one is known)
|
|
60
60
|
|
|
61
|
-
Values are capped at 256 characters and extra labels at 64
|
|
62
|
-
to 32 extras.
|
|
61
|
+
Values are capped at 256 characters and extra labels at 64, and each needs at
|
|
62
|
+
least one visible character; a profile holds up to 32 extras.
|
|
63
63
|
|
|
64
64
|
Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full contract
|
|
65
65
|
(shape, statuses, caching, CORS):
|
|
@@ -69,9 +69,11 @@ 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
|
|
74
|
-
|
|
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.
|
|
75
77
|
- `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
|
|
76
78
|
the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
|
|
77
79
|
`ymmv login` / `ymmv logout` keep acting on the stored login. The CLI asks the server which
|
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) {
|
|
@@ -365,6 +393,12 @@ function isValidHandle(handle) {
|
|
|
365
393
|
return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
|
|
366
394
|
}
|
|
367
395
|
|
|
396
|
+
// ../shared/dist/visible.js
|
|
397
|
+
var VISIBLE_RE = /[^\s\p{Default_Ignorable_Code_Point}\p{Cc}]/u;
|
|
398
|
+
function hasVisibleContent(s) {
|
|
399
|
+
return VISIBLE_RE.test(s);
|
|
400
|
+
}
|
|
401
|
+
|
|
368
402
|
// src/render.ts
|
|
369
403
|
var ESC = String.fromCharCode(27);
|
|
370
404
|
var CSI = `${ESC}[`;
|
|
@@ -397,6 +431,9 @@ var BIDI_RE = new RegExp("\\p{Bidi_Control}", "gu");
|
|
|
397
431
|
function sanitizeValue(value) {
|
|
398
432
|
return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
|
|
399
433
|
}
|
|
434
|
+
function showsVisibleText(value) {
|
|
435
|
+
return hasVisibleContent(sanitizeValue(value));
|
|
436
|
+
}
|
|
400
437
|
function useColor(env, isTTY) {
|
|
401
438
|
if (env.NO_COLOR !== void 0) return false;
|
|
402
439
|
if (env.FORCE_COLOR !== void 0) {
|
|
@@ -410,12 +447,10 @@ function colorEnabled() {
|
|
|
410
447
|
}
|
|
411
448
|
var OSC = `${ESC}]`;
|
|
412
449
|
var ST = `${ESC}\\`;
|
|
413
|
-
function displayUrl(value) {
|
|
414
|
-
return value.trim().replace(/^https:\/\/(?=.)/i, "");
|
|
415
|
-
}
|
|
416
450
|
var HTTP_URL_RE = /^https?:\/\/\S+$/i;
|
|
417
451
|
function isHttpUrl(value) {
|
|
418
|
-
|
|
452
|
+
const t = value.trim();
|
|
453
|
+
return HTTP_URL_RE.test(t) && URL.canParse(t);
|
|
419
454
|
}
|
|
420
455
|
var NO_OSC8_TERMS = /* @__PURE__ */ new Set(["linux", "dumb"]);
|
|
421
456
|
function link(url, color, term = process.env.TERM, label) {
|
|
@@ -571,10 +606,16 @@ function causeText(err) {
|
|
|
571
606
|
return sanitizeValue(text);
|
|
572
607
|
}
|
|
573
608
|
function wireText(text) {
|
|
574
|
-
|
|
575
|
-
|
|
609
|
+
return capped(sanitizeValue(String(text)));
|
|
610
|
+
}
|
|
611
|
+
var WIRE_TEXT_CAP = 200;
|
|
612
|
+
function head(clean) {
|
|
613
|
+
return [...clean.slice(0, WIRE_TEXT_CAP * 2)].slice(0, WIRE_TEXT_CAP).join("");
|
|
614
|
+
}
|
|
615
|
+
function capped(clean) {
|
|
616
|
+
const kept = head(clean);
|
|
617
|
+
return kept.length < clean.length ? `${kept}\u2026` : clean;
|
|
576
618
|
}
|
|
577
|
-
var INVISIBLE_RE = new RegExp("\\p{Default_Ignorable_Code_Point}", "gu");
|
|
578
619
|
async function wireBody(res) {
|
|
579
620
|
try {
|
|
580
621
|
return await res.text();
|
|
@@ -589,8 +630,8 @@ function wireErrorBody(raw) {
|
|
|
589
630
|
const out = {};
|
|
590
631
|
if (typeof body?.error === "string") out.slug = body.error;
|
|
591
632
|
if (typeof body?.message === "string") {
|
|
592
|
-
const clean =
|
|
593
|
-
if (
|
|
633
|
+
const clean = sanitizeValue(body.message);
|
|
634
|
+
if (hasVisibleContent(head(clean))) out.message = capped(clean).trim();
|
|
594
635
|
}
|
|
595
636
|
return out;
|
|
596
637
|
} catch {
|
|
@@ -695,19 +736,23 @@ function parseIdentity(data) {
|
|
|
695
736
|
}
|
|
696
737
|
return { github_id: id, handle };
|
|
697
738
|
}
|
|
739
|
+
function serverBehindHint() {
|
|
740
|
+
return process.env.YMMV_API ? "Point YMMV_API at an up-to-date server." : "The server is behind this CLI release; try again later.";
|
|
741
|
+
}
|
|
698
742
|
var MintRejected = class extends Error {
|
|
699
743
|
constructor(msg) {
|
|
700
744
|
super(msg);
|
|
701
745
|
this.name = "MintRejected";
|
|
702
746
|
}
|
|
703
747
|
};
|
|
704
|
-
async function mintYmmvToken(accessToken) {
|
|
748
|
+
async function mintYmmvToken(accessToken, revoke) {
|
|
705
749
|
const res = await safeFetch(
|
|
706
750
|
`${BASE}/api/v1/auth/token`,
|
|
707
751
|
{
|
|
708
752
|
method: "POST",
|
|
709
753
|
headers: { "content-type": "application/json" },
|
|
710
|
-
|
|
754
|
+
// JSON.stringify drops an undefined `revoke`: no key on the wire when there is nothing to retire.
|
|
755
|
+
body: JSON.stringify({ access_token: accessToken, revoke }),
|
|
711
756
|
// Never follow a redirect: a 30x must fail (the existing `!res.ok` guard rejects the resulting
|
|
712
757
|
// opaqueredirect), not re-POST the GitHub access_token to the redirect target or read a
|
|
713
758
|
// redirected 200 as a successful mint. Mirrors publish/delete in api.ts.
|
|
@@ -742,23 +787,29 @@ async function mintYmmvToken(accessToken) {
|
|
|
742
787
|
throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
|
|
743
788
|
}
|
|
744
789
|
const data = await bodyJson(res);
|
|
745
|
-
const unexpected = `Unexpected response from ${BASE}. Nothing was saved; run
|
|
790
|
+
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.";
|
|
746
791
|
if (!data) throw new Error(unexpected);
|
|
747
792
|
const token = typeof data.token === "string" && data.token.length > 0 ? data.token : null;
|
|
748
793
|
const identity = parseIdentity(data);
|
|
749
|
-
|
|
794
|
+
const revoked = typeof data.revoked === "boolean" ? data.revoked : void 0;
|
|
795
|
+
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;
|
|
796
|
+
if (token === null || identity === null || behind !== null) {
|
|
750
797
|
let revokeFailed = false;
|
|
751
798
|
if (token !== null) {
|
|
752
799
|
await revokeYmmvToken(token, AbortSignal.timeout(REVOKE_CAP_MS)).catch(() => {
|
|
753
800
|
revokeFailed = true;
|
|
754
801
|
});
|
|
755
802
|
}
|
|
803
|
+
const refusal = behind ?? unexpected;
|
|
756
804
|
throw new MintRejected(
|
|
757
|
-
revokeFailed ? `${
|
|
805
|
+
revokeFailed ? `${refusal} The login the server minted could not be revoked.` : refusal
|
|
758
806
|
);
|
|
759
807
|
}
|
|
760
808
|
return { token, ...identity };
|
|
761
809
|
}
|
|
810
|
+
function missingRouteError(missing) {
|
|
811
|
+
return new Error(`${BASE} has no ${missing}. ${serverBehindHint()}`);
|
|
812
|
+
}
|
|
762
813
|
async function fetchWhoami(token) {
|
|
763
814
|
const res = await safeFetch(
|
|
764
815
|
`${BASE}/api/v1/auth/whoami`,
|
|
@@ -773,9 +824,7 @@ async function fetchWhoami(token) {
|
|
|
773
824
|
if (!res.ok) {
|
|
774
825
|
if (res.status === 401) throw new Error(ENV_TOKEN_REJECTED_MINT_AGAIN);
|
|
775
826
|
if (res.status === 404) {
|
|
776
|
-
throw
|
|
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
|
-
);
|
|
827
|
+
throw missingRouteError("identity lookup for YMMV_TOKEN");
|
|
779
828
|
}
|
|
780
829
|
throw new Error(
|
|
781
830
|
withRetryHint(
|
|
@@ -996,6 +1045,9 @@ async function pollForToken(dc, deps = {}) {
|
|
|
996
1045
|
}
|
|
997
1046
|
throw new Error("Device code expired. Run `ymmv login` again.");
|
|
998
1047
|
}
|
|
1048
|
+
function retirable(cred) {
|
|
1049
|
+
return cred != null && cred.base === BASE && cred.token.trim() !== "";
|
|
1050
|
+
}
|
|
999
1051
|
async function login(deps = {}) {
|
|
1000
1052
|
if (!process.stdin.isTTY) {
|
|
1001
1053
|
throw new Error(
|
|
@@ -1028,18 +1080,23 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
1028
1080
|
)
|
|
1029
1081
|
);
|
|
1030
1082
|
const accessToken = await pollForToken(dc, deps);
|
|
1031
|
-
const
|
|
1032
|
-
const
|
|
1083
|
+
const before = await peekCredential();
|
|
1084
|
+
const revoke = retirable(before) ? before.token : void 0;
|
|
1085
|
+
const minted = await mintYmmvToken(accessToken, revoke);
|
|
1086
|
+
const after = await peekCredential();
|
|
1033
1087
|
try {
|
|
1034
1088
|
await saveToken(minted);
|
|
1035
1089
|
} catch (e) {
|
|
1036
1090
|
await revokeYmmvToken(minted.token).catch(() => {
|
|
1091
|
+
console.error(
|
|
1092
|
+
message(`${c.faint}(the login the server minted could not be revoked)${c.reset}`)
|
|
1093
|
+
);
|
|
1037
1094
|
});
|
|
1038
1095
|
throw e;
|
|
1039
1096
|
}
|
|
1040
|
-
if (
|
|
1097
|
+
if (retirable(after) && after.token !== revoke && after.token !== minted.token) {
|
|
1041
1098
|
try {
|
|
1042
|
-
await revokeYmmvToken(
|
|
1099
|
+
await revokeYmmvToken(after.token);
|
|
1043
1100
|
} catch {
|
|
1044
1101
|
console.error(message(`${c.faint}(couldn't revoke the previous session's token)${c.reset}`));
|
|
1045
1102
|
}
|
|
@@ -1058,6 +1115,12 @@ var PublishRefusal = class extends Error {
|
|
|
1058
1115
|
this.name = "PublishRefusal";
|
|
1059
1116
|
}
|
|
1060
1117
|
};
|
|
1118
|
+
var ProfileChanged = class extends PublishRefusal {
|
|
1119
|
+
constructor() {
|
|
1120
|
+
super("Your profile changed since this command read it. Re-run the command.");
|
|
1121
|
+
this.name = "ProfileChanged";
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1061
1124
|
var NOT_PERSISTED = "Login did not persist a token. Run `ymmv login`.";
|
|
1062
1125
|
function assertVerified(cred) {
|
|
1063
1126
|
if (cred.source === "env" && cred.github_id === null) {
|
|
@@ -1099,12 +1162,16 @@ async function ensureLogin() {
|
|
|
1099
1162
|
if (!fresh) throw new Error(NOT_PERSISTED);
|
|
1100
1163
|
return fresh;
|
|
1101
1164
|
}
|
|
1102
|
-
async function publishProfile(profile, expected) {
|
|
1165
|
+
async function publishProfile(profile, expected, opts = {}) {
|
|
1103
1166
|
const send = (c) => safeFetch(
|
|
1104
1167
|
`${BASE}/api/v1/profile`,
|
|
1105
1168
|
{
|
|
1106
1169
|
method: "POST",
|
|
1107
|
-
headers: {
|
|
1170
|
+
headers: {
|
|
1171
|
+
"content-type": "application/json",
|
|
1172
|
+
authorization: `Bearer ${c.token}`,
|
|
1173
|
+
...opts.ifMatch ? { "if-match": opts.ifMatch } : {}
|
|
1174
|
+
},
|
|
1108
1175
|
// Send the login-bound handle, never a caller-guessed one — the official client never claims
|
|
1109
1176
|
// a handle it doesn't own.
|
|
1110
1177
|
body: JSON.stringify({ ...profile, handle: c.handle ?? profile.handle }),
|
|
@@ -1182,6 +1249,7 @@ async function publishProfile(profile, expected) {
|
|
|
1182
1249
|
);
|
|
1183
1250
|
}
|
|
1184
1251
|
}
|
|
1252
|
+
if (res.status === 412) throw new ProfileChanged();
|
|
1185
1253
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
1186
1254
|
if (!res.ok) {
|
|
1187
1255
|
const raw = await wireBody(res);
|
|
@@ -1207,6 +1275,40 @@ async function fetchProfileJson(handle) {
|
|
|
1207
1275
|
}
|
|
1208
1276
|
return parseProfile(await res.json());
|
|
1209
1277
|
}
|
|
1278
|
+
var HEADER_SAFE_TAG = /^[!#-~]{1,128}$/;
|
|
1279
|
+
async function fetchOwnProfile(cred) {
|
|
1280
|
+
assertVerified(cred);
|
|
1281
|
+
const res = await safeFetch(
|
|
1282
|
+
`${BASE}/api/v1/profile`,
|
|
1283
|
+
{
|
|
1284
|
+
headers: { authorization: `Bearer ${cred.token}` },
|
|
1285
|
+
// Never follow a redirect: the bearer must not travel to a redirect target, and a 30x→200
|
|
1286
|
+
// must not read as a profile. Same guard as whoami, mint, logout, publish, and delete.
|
|
1287
|
+
redirect: "manual"
|
|
1288
|
+
},
|
|
1289
|
+
BASE
|
|
1290
|
+
);
|
|
1291
|
+
if (res.status === 401) {
|
|
1292
|
+
throw new Error(
|
|
1293
|
+
cred.source === "env" ? ENV_TOKEN_REJECTED_MINT_AGAIN : "Session expired. Run `ymmv login`, then re-run the command."
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
if (res.status === 404) {
|
|
1297
|
+
const { slug } = wireErrorBody(await wireBody(res));
|
|
1298
|
+
if (slug === "not_found") return null;
|
|
1299
|
+
throw missingRouteError("profile lookup");
|
|
1300
|
+
}
|
|
1301
|
+
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
1302
|
+
if (!res.ok) {
|
|
1303
|
+
const raw = await wireBody(res);
|
|
1304
|
+
throw new Error(withRetryHint(`fetch failed: ${res.status} ${wireText(raw)}`, res));
|
|
1305
|
+
}
|
|
1306
|
+
const profile = parseProfile(await res.json());
|
|
1307
|
+
if (!HEADER_SAFE_TAG.test(profile.updated_at)) {
|
|
1308
|
+
throw new Error(`Unexpected response from ${BASE}. Check YMMV_API, or try again shortly.`);
|
|
1309
|
+
}
|
|
1310
|
+
return { profile, etag: `"${profile.updated_at}"` };
|
|
1311
|
+
}
|
|
1210
1312
|
async function deleteProfile(cred) {
|
|
1211
1313
|
assertVerified(cred);
|
|
1212
1314
|
const res = await safeFetch(
|
|
@@ -1625,7 +1727,31 @@ function pagePointer(handle) {
|
|
|
1625
1727
|
const c = palette(color);
|
|
1626
1728
|
return ` ${c.faint}\u2192 ${color ? displayUrl(BASE) : BASE}/${handle}${c.reset}`;
|
|
1627
1729
|
}
|
|
1628
|
-
|
|
1730
|
+
function writeRuleRefusal(entries, existing) {
|
|
1731
|
+
const saved = savedKeys(existing);
|
|
1732
|
+
for (const { key, value } of entries) {
|
|
1733
|
+
if (!isCuratedKey(key)) continue;
|
|
1734
|
+
const problem = valueProblem(value);
|
|
1735
|
+
if (problem === void 0) continue;
|
|
1736
|
+
const remedy = problem === "invisible" ? "Set one" : "Set a shorter one";
|
|
1737
|
+
const source = saved.has(key) ? "saved" : "detected";
|
|
1738
|
+
const remove = problem === "invisible" && source === "saved" ? `, or remove it: ymmv unset ${key}` : "";
|
|
1739
|
+
return `The ${source} ${KEY_LABELS[key]} value ${ruleClause(problem, value)}. ${remedy}: ymmv set ${key} <value>${remove}.`;
|
|
1740
|
+
}
|
|
1741
|
+
return void 0;
|
|
1742
|
+
}
|
|
1743
|
+
function valueProblem(value) {
|
|
1744
|
+
if (!showsVisibleText(value)) return "invisible";
|
|
1745
|
+
if (value.length > MAX_VALUE) return "over-cap";
|
|
1746
|
+
return void 0;
|
|
1747
|
+
}
|
|
1748
|
+
function ruleClause(problem, value) {
|
|
1749
|
+
return problem === "invisible" ? "has no visible text" : `is ${value.length} characters; the cap is ${MAX_VALUE}`;
|
|
1750
|
+
}
|
|
1751
|
+
function savedKeys(existing) {
|
|
1752
|
+
return new Set((existing?.entries ?? []).map((e) => e.key));
|
|
1753
|
+
}
|
|
1754
|
+
async function promptEntries(defaults, saved, prompter) {
|
|
1629
1755
|
const c = palette(colorEnabled());
|
|
1630
1756
|
console.log(message(`${c.faint}Enter to keep, "-" to clear${c.reset}`));
|
|
1631
1757
|
const chosen = /* @__PURE__ */ new Map();
|
|
@@ -1633,13 +1759,15 @@ async function promptEntries(defaults, prompter) {
|
|
|
1633
1759
|
for (; ; ) {
|
|
1634
1760
|
const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
|
|
1635
1761
|
const value = answer === "-" ? "" : answer;
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
);
|
|
1762
|
+
const rawDefault = defaults.get(key) ?? "";
|
|
1763
|
+
const isDefault = value === sanitizeValue(rawDefault).trim();
|
|
1764
|
+
const emptiedDefault = answer === "" && rawDefault !== "";
|
|
1765
|
+
const problem = emptiedDefault ? "invisible" : value === "" ? void 0 : valueProblem(value);
|
|
1766
|
+
if (problem !== void 0) {
|
|
1767
|
+
const which = `the ${saved.has(key) ? "saved" : "detected"} value`;
|
|
1768
|
+
const clause = ruleClause(problem, value);
|
|
1769
|
+
const note = isDefault ? `${which} ${clause}. Type a ${problem === "invisible" ? "value" : "shorter value"} or - to clear` : `that value ${clause}`;
|
|
1770
|
+
console.log(message(`${c.faint}${note}${c.reset}`));
|
|
1643
1771
|
continue;
|
|
1644
1772
|
}
|
|
1645
1773
|
if (value) chosen.set(key, value);
|
|
@@ -1666,11 +1794,13 @@ async function publish(io) {
|
|
|
1666
1794
|
return readFileSync(p, "utf8");
|
|
1667
1795
|
}
|
|
1668
1796
|
});
|
|
1669
|
-
const
|
|
1797
|
+
const own = await fetchOwnProfile(cred);
|
|
1798
|
+
const existing = own?.profile ?? null;
|
|
1670
1799
|
assertHandleUnchanged(existing, cred, handle);
|
|
1671
1800
|
const defaults = buildDefaults(existing, detected);
|
|
1672
|
-
|
|
1673
|
-
|
|
1801
|
+
let carried = unknownEntries(existing);
|
|
1802
|
+
let extras = existing?.extras ?? [];
|
|
1803
|
+
let ifMatch = own?.etag;
|
|
1674
1804
|
const color = colorEnabled();
|
|
1675
1805
|
const site = displayUrl(BASE);
|
|
1676
1806
|
const showCard = (entries) => {
|
|
@@ -1698,25 +1828,36 @@ async function publish(io) {
|
|
|
1698
1828
|
};
|
|
1699
1829
|
let values = defaults;
|
|
1700
1830
|
const assemble = () => [...entriesFromMap(values), ...carried];
|
|
1831
|
+
const edits = /* @__PURE__ */ new Map();
|
|
1832
|
+
let saved = savedKeys(existing);
|
|
1833
|
+
const prompt = async (prompter) => {
|
|
1834
|
+
const before = values;
|
|
1835
|
+
values = await promptEntries(values, saved, prompter);
|
|
1836
|
+
for (const key of CURATED_KEYS) {
|
|
1837
|
+
const after = values.get(key);
|
|
1838
|
+
if (after !== before.get(key)) edits.set(key, after);
|
|
1839
|
+
}
|
|
1840
|
+
};
|
|
1701
1841
|
if (!io.interactive || !io.prompter || io.yes) {
|
|
1702
|
-
const
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
`The detected ${KEY_LABELS[over[0]]} value is ${over[1].length} characters; the cap is ${MAX_VALUE}. Set a shorter one: ymmv set ${over[0]} <value>.`
|
|
1707
|
-
)
|
|
1708
|
-
);
|
|
1842
|
+
const entries = assemble();
|
|
1843
|
+
const refusal = writeRuleRefusal(entries, existing);
|
|
1844
|
+
if (refusal !== void 0) {
|
|
1845
|
+
console.error(message(refusal));
|
|
1709
1846
|
process.exitCode = 1;
|
|
1710
1847
|
return;
|
|
1711
1848
|
}
|
|
1712
|
-
const entries = assemble();
|
|
1713
1849
|
showCard(entries);
|
|
1714
|
-
printPublished(
|
|
1850
|
+
printPublished(
|
|
1851
|
+
await publishProfile(newProfile(handle, entries, extras), cred, { ifMatch }),
|
|
1852
|
+
color
|
|
1853
|
+
);
|
|
1715
1854
|
return;
|
|
1716
1855
|
}
|
|
1717
1856
|
try {
|
|
1718
|
-
if (!existing)
|
|
1857
|
+
if (!existing) await prompt(io.prompter);
|
|
1858
|
+
const failsRule = ([, v]) => valueProblem(v) !== void 0;
|
|
1719
1859
|
for (; ; ) {
|
|
1860
|
+
if ([...values].some(failsRule)) await prompt(io.prompter);
|
|
1720
1861
|
const entries = assemble();
|
|
1721
1862
|
showCard(entries);
|
|
1722
1863
|
const ans = await io.prompter.choice(
|
|
@@ -1727,9 +1868,38 @@ async function publish(io) {
|
|
|
1727
1868
|
);
|
|
1728
1869
|
if (ans === "y") {
|
|
1729
1870
|
try {
|
|
1730
|
-
printPublished(
|
|
1871
|
+
printPublished(
|
|
1872
|
+
await publishProfile(newProfile(handle, entries, extras), cred, { ifMatch }),
|
|
1873
|
+
color
|
|
1874
|
+
);
|
|
1731
1875
|
return;
|
|
1732
1876
|
} catch (e) {
|
|
1877
|
+
if (e instanceof ProfileChanged) {
|
|
1878
|
+
console.error(
|
|
1879
|
+
message(
|
|
1880
|
+
"Your profile changed since this command read it. Reloaded the current version; your answers are kept."
|
|
1881
|
+
)
|
|
1882
|
+
);
|
|
1883
|
+
const liveCred = cred.source === "env" ? cred : await loadCredential() ?? cred;
|
|
1884
|
+
const fresh = await fetchOwnProfile(liveCred);
|
|
1885
|
+
if (!fresh) {
|
|
1886
|
+
throw new PublishRefusal(
|
|
1887
|
+
"Your profile was deleted since this command read it. Nothing was published. Run `ymmv` again to recreate it."
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
assertHandleUnchanged(fresh.profile, cred, handle);
|
|
1891
|
+
const rebased = buildDefaults(fresh.profile, detected);
|
|
1892
|
+
for (const [key, value] of edits) {
|
|
1893
|
+
if (value === void 0) rebased.delete(key);
|
|
1894
|
+
else rebased.set(key, value);
|
|
1895
|
+
}
|
|
1896
|
+
values = rebased;
|
|
1897
|
+
saved = savedKeys(fresh.profile);
|
|
1898
|
+
carried = unknownEntries(fresh.profile);
|
|
1899
|
+
extras = fresh.profile.extras;
|
|
1900
|
+
ifMatch = fresh.etag;
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1733
1903
|
if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
|
|
1734
1904
|
const ambiguous = e instanceof NetworkError || isTimeoutError(e);
|
|
1735
1905
|
console.error(
|
|
@@ -1745,7 +1915,7 @@ ${ambiguous ? "The publish may not have completed. Your answers are kept." : "No
|
|
|
1745
1915
|
console.log(message("Aborted. Nothing published."));
|
|
1746
1916
|
return;
|
|
1747
1917
|
}
|
|
1748
|
-
|
|
1918
|
+
await prompt(io.prompter);
|
|
1749
1919
|
}
|
|
1750
1920
|
} catch (e) {
|
|
1751
1921
|
if (e instanceof PromptAborted) {
|
|
@@ -1812,7 +1982,8 @@ async function runSet(target) {
|
|
|
1812
1982
|
const cred = await ensureLogin();
|
|
1813
1983
|
const handle = requireHandle(cred);
|
|
1814
1984
|
if (!handle) return;
|
|
1815
|
-
const
|
|
1985
|
+
const own = await fetchOwnProfile(cred);
|
|
1986
|
+
const existing = own?.profile ?? null;
|
|
1816
1987
|
assertHandleUnchanged(existing, cred, handle);
|
|
1817
1988
|
const { entries, extras } = applySet(existing, target);
|
|
1818
1989
|
if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
|
|
@@ -1824,15 +1995,33 @@ async function runSet(target) {
|
|
|
1824
1995
|
process.exitCode = 1;
|
|
1825
1996
|
return;
|
|
1826
1997
|
}
|
|
1827
|
-
const
|
|
1828
|
-
|
|
1998
|
+
const refusal = writeRuleRefusal(entries, existing);
|
|
1999
|
+
if (refusal !== void 0) {
|
|
2000
|
+
console.error(message(refusal));
|
|
2001
|
+
process.exitCode = 1;
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
const res = await publishProfile(newProfile(handle, entries, extras), cred, {
|
|
2005
|
+
ifMatch: own?.etag
|
|
2006
|
+
});
|
|
2007
|
+
const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${sanitizeValue(target.value)}.` : `Set extra ${sanitizeValue(target.label)} = ${sanitizeValue(target.value)}.`;
|
|
1829
2008
|
console.log(message(`${line}${pagePointer(res.handle)}`));
|
|
1830
2009
|
}
|
|
2010
|
+
var PASTE_SAFE_LABEL = /^[\p{L}\p{N} _.+/:-]+$/u;
|
|
2011
|
+
function extraHint(existing, label) {
|
|
2012
|
+
const eq = label.indexOf("=");
|
|
2013
|
+
const head2 = eq > 0 ? label.slice(0, eq).trim() : "";
|
|
2014
|
+
if (!head2 || !applyUnset(existing, { kind: "extra", label: head2 }).removed) return "";
|
|
2015
|
+
const shown = PASTE_SAFE_LABEL.test(head2) ? head2 : "Label";
|
|
2016
|
+
return `
|
|
2017
|
+
(unset takes just the label: ymmv unset --extra "${shown}")`;
|
|
2018
|
+
}
|
|
1831
2019
|
async function runUnset(target) {
|
|
1832
2020
|
const cred = await ensureLogin();
|
|
1833
2021
|
const handle = requireHandle(cred);
|
|
1834
2022
|
if (!handle) return;
|
|
1835
|
-
const
|
|
2023
|
+
const own = await fetchOwnProfile(cred);
|
|
2024
|
+
const existing = own?.profile ?? null;
|
|
1836
2025
|
assertHandleUnchanged(existing, cred, handle);
|
|
1837
2026
|
if (!existing) {
|
|
1838
2027
|
console.log(message("No profile yet. Run `ymmv` to publish one."));
|
|
@@ -1840,14 +2029,19 @@ async function runUnset(target) {
|
|
|
1840
2029
|
}
|
|
1841
2030
|
const { entries, extras, removed } = applyUnset(existing, target);
|
|
1842
2031
|
if (!removed) {
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${target.label}".`
|
|
1846
|
-
)
|
|
1847
|
-
);
|
|
2032
|
+
const line2 = target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${sanitizeValue(target.label)}".${extraHint(existing, target.label)}`;
|
|
2033
|
+
console.log(message(line2));
|
|
1848
2034
|
return;
|
|
1849
2035
|
}
|
|
1850
|
-
const
|
|
2036
|
+
const refusal = writeRuleRefusal(entries, existing);
|
|
2037
|
+
if (refusal !== void 0) {
|
|
2038
|
+
console.error(message(refusal));
|
|
2039
|
+
process.exitCode = 1;
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
const res = await publishProfile(newProfile(handle, entries, extras), cred, {
|
|
2043
|
+
ifMatch: own?.etag
|
|
2044
|
+
});
|
|
1851
2045
|
const line = target.kind === "curated" ? `Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").` : `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`;
|
|
1852
2046
|
console.log(message(`${line}${pagePointer(res.handle)}`));
|
|
1853
2047
|
}
|
|
@@ -1893,10 +2087,10 @@ var SET_USAGE = `usage: ymmv set <key> <value> | ${SET_EXTRA}`;
|
|
|
1893
2087
|
var EXTRA_USAGE = `usage: ${SET_EXTRA}`;
|
|
1894
2088
|
var UNSET_USAGE = `usage: ymmv unset <key> | ${UNSET_EXTRA}`;
|
|
1895
2089
|
var VIEW_USAGE = "usage: ymmv view <handle>";
|
|
1896
|
-
function invalidKeyError(
|
|
2090
|
+
function invalidKeyError(head2, hint) {
|
|
1897
2091
|
return {
|
|
1898
2092
|
kind: "error",
|
|
1899
|
-
message: `"${sanitizeValue(
|
|
2093
|
+
message: `"${sanitizeValue(head2)}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
|
|
1900
2094
|
For anything else, use: ${hint}.`
|
|
1901
2095
|
};
|
|
1902
2096
|
}
|
|
@@ -1920,9 +2114,15 @@ function valueCapError(value) {
|
|
|
1920
2114
|
message: `That value is ${value.length} characters; the cap is ${MAX_VALUE}.`
|
|
1921
2115
|
};
|
|
1922
2116
|
}
|
|
2117
|
+
function labelInvisibleError() {
|
|
2118
|
+
return { kind: "error", message: "That label has no visible text." };
|
|
2119
|
+
}
|
|
2120
|
+
function valueInvisibleError() {
|
|
2121
|
+
return { kind: "error", message: "That value has no visible text." };
|
|
2122
|
+
}
|
|
1923
2123
|
function parseSet(rest) {
|
|
1924
|
-
const
|
|
1925
|
-
if (
|
|
2124
|
+
const head2 = rest[0];
|
|
2125
|
+
if (head2 === "--extra" || head2 === "-e") {
|
|
1926
2126
|
const spec = rest.slice(1).join(" ").trim();
|
|
1927
2127
|
const eq = spec.indexOf("=");
|
|
1928
2128
|
if (eq <= 0) return { kind: "error", message: EXTRA_USAGE };
|
|
@@ -1930,35 +2130,33 @@ function parseSet(rest) {
|
|
|
1930
2130
|
const value2 = spec.slice(eq + 1).trim();
|
|
1931
2131
|
if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
|
|
1932
2132
|
if (value2 === "-") return { kind: "unset", target: { kind: "extra", label } };
|
|
2133
|
+
if (!showsVisibleText(label)) return labelInvisibleError();
|
|
2134
|
+
if (!showsVisibleText(value2)) return valueInvisibleError();
|
|
1933
2135
|
if (label.length > MAX_LABEL) return labelCapError(label);
|
|
1934
2136
|
if (value2.length > MAX_VALUE) return valueCapError(value2);
|
|
1935
2137
|
return { kind: "set", target: { kind: "extra", label, value: value2 } };
|
|
1936
2138
|
}
|
|
1937
|
-
if (!
|
|
1938
|
-
if (!isCuratedKey(
|
|
2139
|
+
if (!head2) return { kind: "error", message: SET_USAGE };
|
|
2140
|
+
if (!isCuratedKey(head2)) return invalidKeyError(head2, SET_EXTRA);
|
|
1939
2141
|
const value = rest.slice(1).join(" ").trim();
|
|
1940
|
-
if (!value) return { kind: "error", message: `usage: ymmv set ${
|
|
1941
|
-
if (value === "-") return { kind: "unset", target: { kind: "curated", key:
|
|
2142
|
+
if (!value) return { kind: "error", message: `usage: ymmv set ${head2} <value>` };
|
|
2143
|
+
if (value === "-") return { kind: "unset", target: { kind: "curated", key: head2 } };
|
|
2144
|
+
if (!showsVisibleText(value)) return valueInvisibleError();
|
|
1942
2145
|
if (value.length > MAX_VALUE) return valueCapError(value);
|
|
1943
|
-
return { kind: "set", target: { kind: "curated", key:
|
|
2146
|
+
return { kind: "set", target: { kind: "curated", key: head2, value } };
|
|
1944
2147
|
}
|
|
1945
2148
|
function parseUnset(rest) {
|
|
1946
|
-
const
|
|
1947
|
-
if (
|
|
2149
|
+
const head2 = rest[0];
|
|
2150
|
+
if (head2 === "--extra" || head2 === "-e") {
|
|
1948
2151
|
const label = rest.slice(1).join(" ").trim();
|
|
1949
2152
|
if (!label) return { kind: "error", message: `usage: ${UNSET_EXTRA}` };
|
|
1950
|
-
if (label.
|
|
1951
|
-
return {
|
|
1952
|
-
kind: "error",
|
|
1953
|
-
message: 'unset takes just the label: ymmv unset --extra "Keyboard"'
|
|
1954
|
-
};
|
|
1955
|
-
}
|
|
2153
|
+
if (label.length > MAX_LABEL) return labelCapError(label);
|
|
1956
2154
|
return { kind: "unset", target: { kind: "extra", label } };
|
|
1957
2155
|
}
|
|
1958
|
-
if (!
|
|
1959
|
-
if (!isCuratedKey(
|
|
1960
|
-
if (rest.length > 1) return { kind: "error", message: `usage: ymmv unset ${
|
|
1961
|
-
return { kind: "unset", target: { kind: "curated", key:
|
|
2156
|
+
if (!head2) return { kind: "error", message: UNSET_USAGE };
|
|
2157
|
+
if (!isCuratedKey(head2)) return invalidKeyError(head2, UNSET_EXTRA);
|
|
2158
|
+
if (rest.length > 1) return { kind: "error", message: `usage: ymmv unset ${head2}` };
|
|
2159
|
+
return { kind: "unset", target: { kind: "curated", key: head2 } };
|
|
1962
2160
|
}
|
|
1963
2161
|
function resolveArg(argv) {
|
|
1964
2162
|
const first = argv[0];
|
|
@@ -2317,7 +2515,9 @@ ${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser,
|
|
|
2317
2515
|
font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
|
|
2318
2516
|
async function logout() {
|
|
2319
2517
|
const stored = await loadToken();
|
|
2320
|
-
|
|
2518
|
+
const leftover = stored ? null : await peekCredential();
|
|
2519
|
+
const token = stored?.token ?? (retirable(leftover) ? leftover.token : null);
|
|
2520
|
+
if (token === null) {
|
|
2321
2521
|
const otherBase = await peekBase();
|
|
2322
2522
|
console.log(
|
|
2323
2523
|
message(
|
|
@@ -2328,7 +2528,7 @@ async function logout() {
|
|
|
2328
2528
|
}
|
|
2329
2529
|
let revoked;
|
|
2330
2530
|
try {
|
|
2331
|
-
revoked = await revokeYmmvToken(
|
|
2531
|
+
revoked = await revokeYmmvToken(token);
|
|
2332
2532
|
} catch (e) {
|
|
2333
2533
|
console.error(
|
|
2334
2534
|
message(
|