ymmv-cli 0.7.0 → 0.8.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 +45 -8
- package/dist/cli.js +363 -83
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,18 +40,55 @@ via npm Trusted Publishing, with provenance.
|
|
|
40
40
|
|
|
41
41
|
## Commands
|
|
42
42
|
|
|
43
|
-
- `ymmv` detects, confirms, and publishes (re-run any time to update)
|
|
43
|
+
- `ymmv` detects, confirms, and publishes (re-run any time to update; `ymmv publish` is the same command)
|
|
44
44
|
- `ymmv <handle>` views a profile, or diffs it against yours when you're logged in
|
|
45
45
|
- `ymmv set editor Neovim` changes one value
|
|
46
|
-
- `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own
|
|
46
|
+
- `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own (`-e` works too)
|
|
47
47
|
- `ymmv unset editor` removes one value (`ymmv set editor -` works too); `ymmv unset --extra "Keyboard"` removes an extra
|
|
48
|
-
- `ymmv delete` removes your profile
|
|
48
|
+
- `ymmv delete` removes your profile (`ymmv delete -y` skips the confirm, for scripts)
|
|
49
49
|
- `ymmv login` / `ymmv logout` sign in / out
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
- `ymmv version` prints the CLI version
|
|
51
|
+
|
|
52
|
+
Values are capped at 256 characters and extra labels at 64; a profile holds up
|
|
53
|
+
to 32 extras.
|
|
54
|
+
|
|
55
|
+
Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full contract
|
|
56
|
+
(shape, statuses, caching, CORS):
|
|
57
|
+
<https://github.com/ymmv-fyi/ymmv/blob/main/docs/api.md>.
|
|
58
|
+
|
|
59
|
+
## Environment variables
|
|
60
|
+
|
|
61
|
+
- `NO_COLOR` disables color output (and `FORCE_COLOR=0`/`false` force-disables it).
|
|
62
|
+
- `YMMV_API` points the CLI at a different Worker (development/staging). Bare origin only.
|
|
63
|
+
- `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
|
|
64
|
+
the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
|
|
65
|
+
`ymmv login` / `ymmv logout` keep acting on the stored login. Viewing (`ymmv <handle>`) also
|
|
66
|
+
keeps using the stored login for the you-side of a diff. The token is sent to the server
|
|
67
|
+
`YMMV_API` selects, so set the two together.
|
|
68
|
+
- `YMMV_HANDLE` names the GitHub username `YMMV_TOKEN` belongs to. Required for `ymmv -y` and
|
|
69
|
+
`ymmv set`/`unset` under an env token (there is no server lookup for it); ignored without
|
|
70
|
+
`YMMV_TOKEN`.
|
|
71
|
+
|
|
72
|
+
## Publishing from CI
|
|
73
|
+
|
|
74
|
+
`ymmv login` needs a browser, so mint the token on your machine and hand it to CI:
|
|
75
|
+
|
|
76
|
+
1. Run `ymmv login` locally.
|
|
77
|
+
2. Copy the `token` value from the token file:
|
|
78
|
+
`~/.config/ymmv/token.json` (Linux), `~/Library/Preferences/ymmv/token.json` (macOS),
|
|
79
|
+
`%APPDATA%\ymmv\Config\token.json` (Windows).
|
|
80
|
+
3. Set it as a CI secret named `YMMV_TOKEN`, and set `YMMV_HANDLE` to your GitHub username.
|
|
81
|
+
4. Run `npx ymmv-cli -y` in the job.
|
|
82
|
+
|
|
83
|
+
Two things to know:
|
|
84
|
+
|
|
85
|
+
- `ymmv -y` publishes the merge of your existing profile with what it detects on the machine it
|
|
86
|
+
runs on. Values you already published always win, but curated keys you have never set get the
|
|
87
|
+
CI runner's detected values (its OS, shell, and so on). For targeted updates from CI, prefer
|
|
88
|
+
`ymmv set <key> <value>`.
|
|
89
|
+
- A rejected or revoked `YMMV_TOKEN` fails with an error naming the variable; nothing falls back
|
|
90
|
+
to an interactive login, and the stored login file on the runner (if any) is left untouched.
|
|
91
|
+
`ymmv delete` acts on the account the token is bound to, regardless of `YMMV_HANDLE`.
|
|
55
92
|
|
|
56
93
|
## License
|
|
57
94
|
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// ../shared/dist/caps.js
|
|
4
|
+
var MAX_EXTRAS = 32;
|
|
5
|
+
var MAX_LABEL = 64;
|
|
6
|
+
var MAX_VALUE = 256;
|
|
7
|
+
|
|
3
8
|
// ../shared/dist/keys.js
|
|
4
9
|
var CURATED_KEYS = [
|
|
5
10
|
"editor",
|
|
@@ -276,7 +281,8 @@ function parseProfile(raw) {
|
|
|
276
281
|
if (!isRecord(raw))
|
|
277
282
|
throw new ProfileParseError("profile is not an object");
|
|
278
283
|
if (raw.schema_version !== SCHEMA_VERSION) {
|
|
279
|
-
|
|
284
|
+
const got = String(raw.schema_version);
|
|
285
|
+
throw new ProfileParseError(`unsupported schema_version: ${got.length > 64 ? `${got.slice(0, 64)}\u2026` : got}. Upgrade the ymmv CLI (npm i -g ymmv-cli).`);
|
|
280
286
|
}
|
|
281
287
|
if (typeof raw.handle !== "string")
|
|
282
288
|
throw new ProfileParseError("handle is not a string");
|
|
@@ -334,7 +340,17 @@ function parseProfile(raw) {
|
|
|
334
340
|
|
|
335
341
|
// ../shared/dist/reserved.js
|
|
336
342
|
var RESERVED_ROUTES = ["404", "api", "login", "logout"];
|
|
337
|
-
var CLI_VERBS = [
|
|
343
|
+
var CLI_VERBS = [
|
|
344
|
+
"login",
|
|
345
|
+
"logout",
|
|
346
|
+
"set",
|
|
347
|
+
"unset",
|
|
348
|
+
"delete",
|
|
349
|
+
"view",
|
|
350
|
+
"help",
|
|
351
|
+
"publish",
|
|
352
|
+
"version"
|
|
353
|
+
];
|
|
338
354
|
var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
|
|
339
355
|
var RESERVED_SET = new Set(RESERVED);
|
|
340
356
|
function isReserved(handle) {
|
|
@@ -382,7 +398,9 @@ function sanitizeValue(value) {
|
|
|
382
398
|
}
|
|
383
399
|
function useColor(env, isTTY) {
|
|
384
400
|
if (env.NO_COLOR !== void 0) return false;
|
|
385
|
-
if (env.FORCE_COLOR !== void 0)
|
|
401
|
+
if (env.FORCE_COLOR !== void 0) {
|
|
402
|
+
return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
|
|
403
|
+
}
|
|
386
404
|
if (env.TERM === "dumb") return false;
|
|
387
405
|
return isTTY;
|
|
388
406
|
}
|
|
@@ -442,13 +460,15 @@ function renderProfile(profile, opts) {
|
|
|
442
460
|
const val = (v) => isHttpUrl(v) ? link(v, opts.color) : v;
|
|
443
461
|
const lines = [
|
|
444
462
|
"",
|
|
445
|
-
` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}
|
|
446
|
-
""
|
|
463
|
+
` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`
|
|
447
464
|
];
|
|
448
|
-
|
|
449
|
-
lines.push(
|
|
450
|
-
|
|
451
|
-
|
|
465
|
+
if (rows.length) {
|
|
466
|
+
lines.push("");
|
|
467
|
+
for (const r of rows) {
|
|
468
|
+
lines.push(
|
|
469
|
+
r.value === null ? ` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}` : ` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}`
|
|
470
|
+
);
|
|
471
|
+
}
|
|
452
472
|
}
|
|
453
473
|
if (extras.length) {
|
|
454
474
|
lines.push("");
|
|
@@ -553,17 +573,30 @@ function wireText(text) {
|
|
|
553
573
|
return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
|
|
554
574
|
}
|
|
555
575
|
var INVISIBLE_RE = new RegExp("\\p{Default_Ignorable_Code_Point}", "gu");
|
|
556
|
-
async function
|
|
576
|
+
async function wireBody(res) {
|
|
557
577
|
try {
|
|
558
|
-
|
|
578
|
+
return await res.text();
|
|
579
|
+
} catch (err) {
|
|
580
|
+
if (isTimeoutError(err)) throw err;
|
|
581
|
+
return "";
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function wireErrorBody(raw) {
|
|
585
|
+
try {
|
|
586
|
+
const body = JSON.parse(raw);
|
|
587
|
+
const out = {};
|
|
588
|
+
if (typeof body?.error === "string") out.slug = body.error;
|
|
559
589
|
if (typeof body?.message === "string") {
|
|
560
590
|
const clean = wireText(body.message).trim();
|
|
561
|
-
if (clean.replace(INVISIBLE_RE, "").trim())
|
|
591
|
+
if (clean.replace(INVISIBLE_RE, "").trim()) out.message = clean;
|
|
562
592
|
}
|
|
563
|
-
|
|
564
|
-
|
|
593
|
+
return out;
|
|
594
|
+
} catch {
|
|
595
|
+
return {};
|
|
565
596
|
}
|
|
566
|
-
|
|
597
|
+
}
|
|
598
|
+
async function serverMessage(res) {
|
|
599
|
+
return wireErrorBody(await wireBody(res)).message;
|
|
567
600
|
}
|
|
568
601
|
function withRetryHint(msg, res) {
|
|
569
602
|
const retry = res.headers.get("retry-after");
|
|
@@ -574,6 +607,12 @@ function displayError(err) {
|
|
|
574
607
|
const text = err instanceof Error ? err.message : String(err);
|
|
575
608
|
return text.split(/\r?\n/).map((line) => sanitizeValue(line)).join("\n");
|
|
576
609
|
}
|
|
610
|
+
var NetworkError = class extends Error {
|
|
611
|
+
constructor(message2, options) {
|
|
612
|
+
super(message2, options);
|
|
613
|
+
this.name = "NetworkError";
|
|
614
|
+
}
|
|
615
|
+
};
|
|
577
616
|
async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
578
617
|
try {
|
|
579
618
|
return await fetchFn(url, {
|
|
@@ -581,7 +620,7 @@ async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
|
581
620
|
signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
582
621
|
});
|
|
583
622
|
} catch (err) {
|
|
584
|
-
throw new
|
|
623
|
+
throw new NetworkError(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
|
|
585
624
|
cause: err
|
|
586
625
|
});
|
|
587
626
|
}
|
|
@@ -591,7 +630,51 @@ async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
|
591
630
|
import { readFileSync as readFileSync2 } from "fs";
|
|
592
631
|
|
|
593
632
|
// src/config.ts
|
|
594
|
-
|
|
633
|
+
function normalizeBase(raw) {
|
|
634
|
+
return raw.replace(/\/+$/, "");
|
|
635
|
+
}
|
|
636
|
+
var BASE = normalizeBase(process.env.YMMV_API || "https://ymmv.fyi");
|
|
637
|
+
function baseProblem(raw = process.env.YMMV_API) {
|
|
638
|
+
if (raw === void 0 || raw === "") return null;
|
|
639
|
+
const shown = `YMMV_API is set to "${sanitizeValue(raw)}"`;
|
|
640
|
+
if (/\s/.test(raw)) return `${shown} which contains whitespace. Remove it.`;
|
|
641
|
+
const base = normalizeBase(raw);
|
|
642
|
+
let url;
|
|
643
|
+
try {
|
|
644
|
+
url = new URL(base);
|
|
645
|
+
} catch {
|
|
646
|
+
return `${shown} which is not a full URL. Use a bare origin like https://ymmv.fyi (the http:// or https:// scheme is required).`;
|
|
647
|
+
}
|
|
648
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
649
|
+
return `${shown} which is not an http or https URL. Use a bare origin like https://ymmv.fyi.`;
|
|
650
|
+
}
|
|
651
|
+
if (url.username || url.password) {
|
|
652
|
+
return `${shown} which contains credentials. Use a bare origin like https://ymmv.fyi.`;
|
|
653
|
+
}
|
|
654
|
+
if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
|
|
655
|
+
return `${shown} which is not a bare origin. Drop the path (the Worker's redirects are root-absolute, so a path-mounted base breaks): use just the scheme and host, like https://ymmv.fyi.`;
|
|
656
|
+
}
|
|
657
|
+
if (base !== url.origin) {
|
|
658
|
+
return `${shown} which is not in canonical form. Use exactly the scheme and host, like https://ymmv.fyi.`;
|
|
659
|
+
}
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
function credentialEnvProblem(rawToken = process.env.YMMV_TOKEN, rawHandle = process.env.YMMV_HANDLE) {
|
|
663
|
+
if (rawToken === void 0 || rawToken === "") return null;
|
|
664
|
+
if (!/^[\x21-\x7E]+$/.test(rawToken)) {
|
|
665
|
+
return "YMMV_TOKEN contains whitespace, control, or non-ASCII characters. Set it to the exact token value.";
|
|
666
|
+
}
|
|
667
|
+
if (rawHandle !== void 0 && rawHandle !== "") {
|
|
668
|
+
const shown = `YMMV_HANDLE is set to "${sanitizeValue(rawHandle)}"`;
|
|
669
|
+
if (!isValidHandle(rawHandle)) {
|
|
670
|
+
return `${shown} which is not a valid GitHub username. Set it to the handle bound to YMMV_TOKEN.`;
|
|
671
|
+
}
|
|
672
|
+
if (isReserved(rawHandle)) {
|
|
673
|
+
return `${shown} which is a reserved word, so no handle can be bound to it.`;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
595
678
|
|
|
596
679
|
// src/auth-http.ts
|
|
597
680
|
async function bodyJson(res) {
|
|
@@ -631,7 +714,16 @@ async function mintYmmvToken(accessToken) {
|
|
|
631
714
|
);
|
|
632
715
|
}
|
|
633
716
|
const body = await bodyJson(res) ?? {};
|
|
634
|
-
|
|
717
|
+
const slug = typeof body.error === "string" ? body.error : "";
|
|
718
|
+
if (res.status === 401 && slug === "github_auth_failed") {
|
|
719
|
+
throw new Error("GitHub rejected the authorization. Run `ymmv login` to try again.");
|
|
720
|
+
}
|
|
721
|
+
if (res.status === 500 && slug === "internal_error") {
|
|
722
|
+
throw new Error(
|
|
723
|
+
"The server hit an error minting your login. Run `ymmv login` again shortly."
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
|
|
635
727
|
}
|
|
636
728
|
const data = await bodyJson(res);
|
|
637
729
|
if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
|
|
@@ -690,38 +782,49 @@ async function saveToken(data) {
|
|
|
690
782
|
throw e;
|
|
691
783
|
}
|
|
692
784
|
}
|
|
693
|
-
async function
|
|
694
|
-
let raw;
|
|
785
|
+
async function readTokenFile() {
|
|
695
786
|
try {
|
|
696
|
-
|
|
787
|
+
const parsed = JSON.parse(await readFile(tokenFilePath(), "utf8"));
|
|
788
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
697
789
|
} catch {
|
|
698
790
|
return null;
|
|
699
791
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
792
|
+
}
|
|
793
|
+
async function loadToken() {
|
|
794
|
+
const parsed = await readTokenFile();
|
|
795
|
+
if (!parsed || parsed.base !== BASE || typeof parsed.token !== "string" || parsed.token === "" || parsed.handle !== null && typeof parsed.handle !== "string") {
|
|
704
796
|
return null;
|
|
705
797
|
}
|
|
706
|
-
if (parsed.base !== BASE || typeof parsed.token !== "string") return null;
|
|
707
798
|
return parsed;
|
|
708
799
|
}
|
|
800
|
+
async function peekCredential() {
|
|
801
|
+
const parsed = await readTokenFile();
|
|
802
|
+
return parsed && typeof parsed.base === "string" && typeof parsed.token === "string" && parsed.token !== "" ? { base: parsed.base, token: parsed.token } : null;
|
|
803
|
+
}
|
|
804
|
+
async function loadCredential() {
|
|
805
|
+
const envToken = process.env.YMMV_TOKEN || "";
|
|
806
|
+
if (envToken !== "") {
|
|
807
|
+
return { base: BASE, token: envToken, handle: process.env.YMMV_HANDLE || null, source: "env" };
|
|
808
|
+
}
|
|
809
|
+
const stored = await loadToken();
|
|
810
|
+
return stored ? { ...stored, source: "file" } : null;
|
|
811
|
+
}
|
|
709
812
|
async function deleteToken() {
|
|
710
813
|
await rm(tokenFilePath(), { force: true });
|
|
711
814
|
}
|
|
712
815
|
async function peekBase() {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
return typeof parsed.base === "string" ? parsed.base : null;
|
|
716
|
-
} catch {
|
|
717
|
-
return null;
|
|
718
|
-
}
|
|
816
|
+
const parsed = await readTokenFile();
|
|
817
|
+
return typeof parsed?.base === "string" ? parsed.base : null;
|
|
719
818
|
}
|
|
720
819
|
|
|
721
820
|
// src/device-flow.ts
|
|
722
821
|
var DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
723
822
|
var TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
724
823
|
var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
824
|
+
var DEFAULT_POLL_INTERVAL_S = 5;
|
|
825
|
+
function usableInterval(v) {
|
|
826
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 900;
|
|
827
|
+
}
|
|
725
828
|
async function requestDeviceCode(deps = {}) {
|
|
726
829
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
727
830
|
const res = await safeFetch(
|
|
@@ -741,7 +844,10 @@ async function requestDeviceCode(deps = {}) {
|
|
|
741
844
|
if (isTimeoutError(err)) throw err;
|
|
742
845
|
return null;
|
|
743
846
|
});
|
|
744
|
-
if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" ||
|
|
847
|
+
if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || // expires_in gets the same rigor as interval: NaN makes the deadline NaN (instant false
|
|
848
|
+
// "expired"), Infinity/1e300 make it unreachable (a middlebox feeding parseable
|
|
849
|
+
// authorization_pending bodies would hold the login forever - the deadline is the only exit).
|
|
850
|
+
typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || data.expires_in > 86400 || data.interval !== void 0 && !usableInterval(data.interval)) {
|
|
745
851
|
throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
|
|
746
852
|
}
|
|
747
853
|
return data;
|
|
@@ -750,7 +856,7 @@ async function pollForToken(dc, deps = {}) {
|
|
|
750
856
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
751
857
|
const sleep = deps.sleep ?? realSleep;
|
|
752
858
|
const now = deps.now ?? Date.now;
|
|
753
|
-
let interval = dc.interval
|
|
859
|
+
let interval = usableInterval(dc.interval) ? dc.interval : DEFAULT_POLL_INTERVAL_S;
|
|
754
860
|
const deadline = now() + dc.expires_in * 1e3;
|
|
755
861
|
let transientFailures = 0;
|
|
756
862
|
let lastCause = "";
|
|
@@ -802,7 +908,7 @@ async function pollForToken(dc, deps = {}) {
|
|
|
802
908
|
case "authorization_pending":
|
|
803
909
|
break;
|
|
804
910
|
case "slow_down":
|
|
805
|
-
interval = Math.max(tok.interval
|
|
911
|
+
interval = usableInterval(tok.interval) ? Math.max(tok.interval, interval + DEFAULT_POLL_INTERVAL_S) : interval + DEFAULT_POLL_INTERVAL_S;
|
|
806
912
|
break;
|
|
807
913
|
case "access_denied":
|
|
808
914
|
throw new Error("Authorization denied. Run `ymmv login` to try again.");
|
|
@@ -820,9 +926,24 @@ async function login(deps = {}) {
|
|
|
820
926
|
"Device login needs an interactive terminal. Run `ymmv login` in a real terminal (a piped or CI shell can't complete the GitHub device flow)."
|
|
821
927
|
);
|
|
822
928
|
}
|
|
823
|
-
|
|
929
|
+
if (process.env.YMMV_TOKEN) {
|
|
930
|
+
console.error(
|
|
931
|
+
message(
|
|
932
|
+
"YMMV_TOKEN is set and takes precedence over stored logins. This login will be saved but not used until you unset it."
|
|
933
|
+
)
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
const prior = await peekCredential();
|
|
824
937
|
const color = colorEnabled();
|
|
825
938
|
const c = palette(color);
|
|
939
|
+
if (prior && prior.base !== BASE) {
|
|
940
|
+
console.error(
|
|
941
|
+
message(
|
|
942
|
+
`You're logged in to ${sanitizeValue(prior.base)}. Logging in here replaces that stored token. To revoke it first, set YMMV_API to that server and run \`ymmv logout\`.`
|
|
943
|
+
)
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
const dc = await requestDeviceCode(deps);
|
|
826
947
|
const verifyUri = /^https:\/\/github\.com\//.test(dc.verification_uri) ? link(dc.verification_uri, color) : sanitizeValue(dc.verification_uri);
|
|
827
948
|
console.log(
|
|
828
949
|
message(
|
|
@@ -832,6 +953,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
832
953
|
);
|
|
833
954
|
const accessToken = await pollForToken(dc, deps);
|
|
834
955
|
const { token, handle } = await mintYmmvToken(accessToken);
|
|
956
|
+
const replaced = await peekCredential();
|
|
835
957
|
try {
|
|
836
958
|
await saveToken({ token, handle });
|
|
837
959
|
} catch (e) {
|
|
@@ -839,6 +961,13 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
839
961
|
});
|
|
840
962
|
throw e;
|
|
841
963
|
}
|
|
964
|
+
if (replaced && replaced.base === BASE && replaced.token !== token) {
|
|
965
|
+
try {
|
|
966
|
+
await revokeYmmvToken(replaced.token);
|
|
967
|
+
} catch {
|
|
968
|
+
console.error(message(`${c.faint}(couldn't revoke the previous session's token)${c.reset}`));
|
|
969
|
+
}
|
|
970
|
+
}
|
|
842
971
|
console.log(
|
|
843
972
|
message(
|
|
844
973
|
handle ? `Logged in as ${handle}.` : "Logged in. No handle bound (your GitHub username is a reserved word)."
|
|
@@ -847,14 +976,21 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
847
976
|
}
|
|
848
977
|
|
|
849
978
|
// src/api.ts
|
|
979
|
+
var PublishRefusal = class extends Error {
|
|
980
|
+
constructor(msg) {
|
|
981
|
+
super(msg);
|
|
982
|
+
this.name = "PublishRefusal";
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (revoked or expired).";
|
|
850
986
|
async function rateLimitMessage(res) {
|
|
851
987
|
return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
|
|
852
988
|
}
|
|
853
989
|
async function ensureLogin() {
|
|
854
|
-
const existing = await
|
|
990
|
+
const existing = await loadCredential();
|
|
855
991
|
if (existing) return existing;
|
|
856
992
|
await login();
|
|
857
|
-
const fresh = await
|
|
993
|
+
const fresh = await loadCredential();
|
|
858
994
|
if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
|
|
859
995
|
return fresh;
|
|
860
996
|
}
|
|
@@ -874,35 +1010,60 @@ async function publishProfile(profile) {
|
|
|
874
1010
|
);
|
|
875
1011
|
let cred = await ensureLogin();
|
|
876
1012
|
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
877
|
-
throw new
|
|
1013
|
+
throw new PublishRefusal(
|
|
878
1014
|
"The stored login changed while this command was running. Re-run it under the current account."
|
|
879
1015
|
);
|
|
880
1016
|
}
|
|
881
1017
|
let res = await send(cred);
|
|
882
1018
|
if (res.status === 401 || res.status === 409) {
|
|
1019
|
+
if (cred.source === "env") {
|
|
1020
|
+
throw new PublishRefusal(
|
|
1021
|
+
res.status === 401 ? `${ENV_TOKEN_REJECTED} Mint a new one with \`ymmv login\` on an interactive machine and update YMMV_TOKEN.` : "The server no longer accepts this handle for the YMMV_TOKEN account. Update YMMV_HANDLE, or mint a fresh token with `ymmv login`."
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
883
1024
|
const was401 = res.status === 401;
|
|
1025
|
+
console.log(
|
|
1026
|
+
message(
|
|
1027
|
+
was401 ? "Session expired. Logging in again to retry the publish." : "The server no longer recognizes your handle. Logging in again to retry the publish."
|
|
1028
|
+
)
|
|
1029
|
+
);
|
|
884
1030
|
if (was401) await deleteToken();
|
|
885
1031
|
await login();
|
|
886
1032
|
cred = await ensureLogin();
|
|
887
1033
|
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
888
1034
|
const bound = sanitizeValue(cred.handle ?? "");
|
|
889
|
-
throw new
|
|
1035
|
+
throw new PublishRefusal(
|
|
890
1036
|
was401 ? `The re-login bound a different account ("${bound}", not "${sanitizeValue(profile.handle)}"). Nothing was published. Re-run under the account you meant.` : `Your login now binds "${bound}". Nothing was published. Re-run the command to publish under it.`
|
|
891
1037
|
);
|
|
892
1038
|
}
|
|
893
1039
|
res = await send(cred);
|
|
894
|
-
if (res.status === 401) throw new
|
|
1040
|
+
if (res.status === 401) throw new PublishRefusal("Authentication failed. Run `ymmv login`.");
|
|
895
1041
|
if (res.status === 409) {
|
|
1042
|
+
const { slug, message: srvMsg } = wireErrorBody(await wireBody(res));
|
|
1043
|
+
if (slug === "handle_not_bound") {
|
|
1044
|
+
throw new PublishRefusal(
|
|
1045
|
+
"The server still refuses this handle after a fresh login. Wait a moment and re-run the command."
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
896
1048
|
throw new Error(
|
|
897
|
-
|
|
1049
|
+
srvMsg ?? "that handle is taken by another account (your GitHub handle may have been reused)."
|
|
898
1050
|
);
|
|
899
1051
|
}
|
|
900
1052
|
}
|
|
901
1053
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
902
1054
|
if (!res.ok) {
|
|
903
|
-
|
|
1055
|
+
const raw = await wireBody(res);
|
|
1056
|
+
const parsed = wireErrorBody(raw);
|
|
1057
|
+
if (res.status === 400 && parsed.slug === "unsupported_schema_version") {
|
|
1058
|
+
throw new PublishRefusal(parsed.message ?? `publish failed: ${res.status} ${wireText(raw)}`);
|
|
1059
|
+
}
|
|
1060
|
+
throw new Error(parsed.message ?? `publish failed: ${res.status} ${wireText(raw)}`);
|
|
1061
|
+
}
|
|
1062
|
+
let data = {};
|
|
1063
|
+
try {
|
|
1064
|
+
data = await res.json();
|
|
1065
|
+
} catch {
|
|
904
1066
|
}
|
|
905
|
-
const data = await res.json();
|
|
906
1067
|
const shown = typeof data.handle === "string" ? sanitizeValue(data.handle) : profile.handle;
|
|
907
1068
|
return { handle: shown, url: `${BASE}/${shown}` };
|
|
908
1069
|
}
|
|
@@ -914,8 +1075,7 @@ async function fetchProfileJson(handle) {
|
|
|
914
1075
|
}
|
|
915
1076
|
return parseProfile(await res.json());
|
|
916
1077
|
}
|
|
917
|
-
async function deleteProfile() {
|
|
918
|
-
const cred = await ensureLogin();
|
|
1078
|
+
async function deleteProfile(cred) {
|
|
919
1079
|
const res = await safeFetch(
|
|
920
1080
|
`${BASE}/api/v1/profile`,
|
|
921
1081
|
{
|
|
@@ -926,11 +1086,14 @@ async function deleteProfile() {
|
|
|
926
1086
|
BASE
|
|
927
1087
|
);
|
|
928
1088
|
if (res.status === 401) {
|
|
929
|
-
throw new Error(
|
|
1089
|
+
throw new Error(
|
|
1090
|
+
cred.source === "env" ? `${ENV_TOKEN_REJECTED} Update YMMV_TOKEN and run \`ymmv delete\` again.` : "Session expired. Run `ymmv login`, then `ymmv delete` again."
|
|
1091
|
+
);
|
|
930
1092
|
}
|
|
931
1093
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
932
1094
|
if (!res.ok) {
|
|
933
|
-
|
|
1095
|
+
const raw = await wireBody(res);
|
|
1096
|
+
throw new Error(wireErrorBody(raw).message ?? `delete failed: ${res.status} ${wireText(raw)}`);
|
|
934
1097
|
}
|
|
935
1098
|
}
|
|
936
1099
|
|
|
@@ -1299,7 +1462,7 @@ function requireHandle(cred) {
|
|
|
1299
1462
|
if (cred.handle) return cred.handle;
|
|
1300
1463
|
console.error(
|
|
1301
1464
|
message(
|
|
1302
|
-
"Your GitHub username is a reserved word, so no handle is bound. Rename on GitHub, then run `ymmv login` again."
|
|
1465
|
+
cred.source === "env" ? "YMMV_TOKEN is set but YMMV_HANDLE is not. Set YMMV_HANDLE to the GitHub username the token belongs to." : "Your GitHub username is a reserved word, so no handle is bound. Rename on GitHub, then run `ymmv login` again."
|
|
1303
1466
|
)
|
|
1304
1467
|
);
|
|
1305
1468
|
process.exitCode = 1;
|
|
@@ -1334,9 +1497,21 @@ async function promptEntries(defaults, prompter) {
|
|
|
1334
1497
|
console.log(message(`${c.faint}Enter to keep, "-" to clear${c.reset}`));
|
|
1335
1498
|
const chosen = /* @__PURE__ */ new Map();
|
|
1336
1499
|
for (const key of CURATED_KEYS) {
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1500
|
+
for (; ; ) {
|
|
1501
|
+
const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
|
|
1502
|
+
const value = answer === "-" ? "" : answer;
|
|
1503
|
+
if (value.length > MAX_VALUE) {
|
|
1504
|
+
const isDefault = value === (defaults.get(key) ?? "").trim();
|
|
1505
|
+
console.log(
|
|
1506
|
+
message(
|
|
1507
|
+
`${c.faint}${isDefault ? `the saved value is ${value.length} characters; the cap is ${MAX_VALUE}. Type a shorter value or - to clear` : `that value is ${value.length} characters; the cap is ${MAX_VALUE}`}${c.reset}`
|
|
1508
|
+
)
|
|
1509
|
+
);
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
if (value) chosen.set(key, value);
|
|
1513
|
+
break;
|
|
1514
|
+
}
|
|
1340
1515
|
}
|
|
1341
1516
|
return chosen;
|
|
1342
1517
|
}
|
|
@@ -1391,6 +1566,16 @@ async function publish(io) {
|
|
|
1391
1566
|
let values = defaults;
|
|
1392
1567
|
const assemble = () => [...entriesFromMap(values), ...carried];
|
|
1393
1568
|
if (!io.interactive || !io.prompter || io.yes) {
|
|
1569
|
+
const over = [...values].find(([, v]) => v.length > MAX_VALUE);
|
|
1570
|
+
if (over) {
|
|
1571
|
+
console.error(
|
|
1572
|
+
message(
|
|
1573
|
+
`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>.`
|
|
1574
|
+
)
|
|
1575
|
+
);
|
|
1576
|
+
process.exitCode = 1;
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1394
1579
|
const entries = assemble();
|
|
1395
1580
|
showCard(entries);
|
|
1396
1581
|
printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
|
|
@@ -1408,8 +1593,20 @@ async function publish(io) {
|
|
|
1408
1593
|
"Y/n/e=edit"
|
|
1409
1594
|
);
|
|
1410
1595
|
if (ans === "y") {
|
|
1411
|
-
|
|
1412
|
-
|
|
1596
|
+
try {
|
|
1597
|
+
printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
|
|
1598
|
+
return;
|
|
1599
|
+
} catch (e) {
|
|
1600
|
+
if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
|
|
1601
|
+
const ambiguous = e instanceof NetworkError || isTimeoutError(e);
|
|
1602
|
+
console.error(
|
|
1603
|
+
message(
|
|
1604
|
+
`${displayError(e)}
|
|
1605
|
+
${ambiguous ? "The publish may not have completed. Your answers are kept." : "Nothing was published. Your answers are kept."}`
|
|
1606
|
+
)
|
|
1607
|
+
);
|
|
1608
|
+
continue;
|
|
1609
|
+
}
|
|
1413
1610
|
}
|
|
1414
1611
|
if (ans === "n") {
|
|
1415
1612
|
console.log(message("Aborted. Nothing published."));
|
|
@@ -1436,7 +1633,11 @@ async function view(handle) {
|
|
|
1436
1633
|
}
|
|
1437
1634
|
const cred = await loadToken();
|
|
1438
1635
|
if (cred?.handle) {
|
|
1439
|
-
|
|
1636
|
+
let mineFailed = false;
|
|
1637
|
+
const mine = await fetchProfileJson(cred.handle).catch(() => {
|
|
1638
|
+
mineFailed = true;
|
|
1639
|
+
return null;
|
|
1640
|
+
});
|
|
1440
1641
|
if (mine && mine.handle.toLowerCase() !== theirs.handle.toLowerCase()) {
|
|
1441
1642
|
console.log(
|
|
1442
1643
|
renderDiff(diff(mine, theirs), { color: c, theirsLabel: theirs.handle, mineLabel: "you" })
|
|
@@ -1445,7 +1646,12 @@ async function view(handle) {
|
|
|
1445
1646
|
}
|
|
1446
1647
|
if (!mine) {
|
|
1447
1648
|
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
1448
|
-
|
|
1649
|
+
if (mineFailed) {
|
|
1650
|
+
const codes = palette(c);
|
|
1651
|
+
console.error(message(`${codes.faint}(couldn't load your profile to diff)${codes.reset}`));
|
|
1652
|
+
} else {
|
|
1653
|
+
console.log(nudge(c));
|
|
1654
|
+
}
|
|
1449
1655
|
return;
|
|
1450
1656
|
}
|
|
1451
1657
|
}
|
|
@@ -1458,6 +1664,15 @@ async function runSet(target) {
|
|
|
1458
1664
|
const existing = await fetchProfileJson(handle);
|
|
1459
1665
|
assertHandleUnchanged(existing, handle);
|
|
1460
1666
|
const { entries, extras } = applySet(existing, target);
|
|
1667
|
+
if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
|
|
1668
|
+
console.error(
|
|
1669
|
+
message(
|
|
1670
|
+
`Your profile already has ${MAX_EXTRAS} extras; that's the cap. Remove one first: ymmv unset --extra "Label".`
|
|
1671
|
+
)
|
|
1672
|
+
);
|
|
1673
|
+
process.exitCode = 1;
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1461
1676
|
const res = await publishProfile(newProfile(handle, entries, extras));
|
|
1462
1677
|
const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
|
|
1463
1678
|
console.log(message(`${line}${pagePointer(res.handle)}`));
|
|
@@ -1487,7 +1702,7 @@ async function runUnset(target) {
|
|
|
1487
1702
|
}
|
|
1488
1703
|
async function runDelete(io) {
|
|
1489
1704
|
const cred = await ensureLogin();
|
|
1490
|
-
const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
|
|
1705
|
+
const target = cred.source === "env" ? "the profile bound to YMMV_TOKEN" : cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
|
|
1491
1706
|
if (!io.yes) {
|
|
1492
1707
|
if (!io.interactive || !io.prompter) {
|
|
1493
1708
|
console.error(
|
|
@@ -1515,8 +1730,8 @@ ${message("Cancelled. Nothing deleted.")}`);
|
|
|
1515
1730
|
return;
|
|
1516
1731
|
}
|
|
1517
1732
|
}
|
|
1518
|
-
await deleteProfile();
|
|
1519
|
-
await deleteToken();
|
|
1733
|
+
await deleteProfile(cred);
|
|
1734
|
+
if (cred.source === "file") await deleteToken();
|
|
1520
1735
|
console.log(message(`Deleted ${target}. Run \`ymmv\` to publish again.`));
|
|
1521
1736
|
}
|
|
1522
1737
|
|
|
@@ -1526,15 +1741,33 @@ var UNSET_EXTRA = 'ymmv unset --extra "Label"';
|
|
|
1526
1741
|
var SET_USAGE = `usage: ymmv set <key> <value> | ${SET_EXTRA}`;
|
|
1527
1742
|
var EXTRA_USAGE = `usage: ${SET_EXTRA}`;
|
|
1528
1743
|
var UNSET_USAGE = `usage: ymmv unset <key> | ${UNSET_EXTRA}`;
|
|
1744
|
+
var VIEW_USAGE = "usage: ymmv view <handle>";
|
|
1529
1745
|
function invalidKeyError(head, hint) {
|
|
1530
1746
|
return {
|
|
1531
1747
|
kind: "error",
|
|
1532
|
-
message: `"${head}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
|
|
1748
|
+
message: `"${sanitizeValue(head)}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
|
|
1533
1749
|
For anything else, use: ${hint}.`
|
|
1534
1750
|
};
|
|
1535
1751
|
}
|
|
1536
|
-
function
|
|
1537
|
-
return
|
|
1752
|
+
function noArgs(verb, rest) {
|
|
1753
|
+
return rest.length === 0 ? { kind: verb } : { kind: "error", message: `usage: ymmv ${verb}` };
|
|
1754
|
+
}
|
|
1755
|
+
function yesOnly(usage, rest, make) {
|
|
1756
|
+
if (rest.length === 0) return make(false);
|
|
1757
|
+
if (rest.length === 1 && (rest[0] === "-y" || rest[0] === "--yes")) return make(true);
|
|
1758
|
+
return { kind: "error", message: usage };
|
|
1759
|
+
}
|
|
1760
|
+
function labelCapError(label) {
|
|
1761
|
+
return {
|
|
1762
|
+
kind: "error",
|
|
1763
|
+
message: `That label is ${label.length} characters; the cap is ${MAX_LABEL}.`
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
function valueCapError(value) {
|
|
1767
|
+
return {
|
|
1768
|
+
kind: "error",
|
|
1769
|
+
message: `That value is ${value.length} characters; the cap is ${MAX_VALUE}.`
|
|
1770
|
+
};
|
|
1538
1771
|
}
|
|
1539
1772
|
function parseSet(rest) {
|
|
1540
1773
|
const head = rest[0];
|
|
@@ -1546,6 +1779,8 @@ function parseSet(rest) {
|
|
|
1546
1779
|
const value2 = spec.slice(eq + 1).trim();
|
|
1547
1780
|
if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
|
|
1548
1781
|
if (value2 === "-") return { kind: "unset", target: { kind: "extra", label } };
|
|
1782
|
+
if (label.length > MAX_LABEL) return labelCapError(label);
|
|
1783
|
+
if (value2.length > MAX_VALUE) return valueCapError(value2);
|
|
1549
1784
|
return { kind: "set", target: { kind: "extra", label, value: value2 } };
|
|
1550
1785
|
}
|
|
1551
1786
|
if (!head) return { kind: "error", message: SET_USAGE };
|
|
@@ -1553,6 +1788,7 @@ function parseSet(rest) {
|
|
|
1553
1788
|
const value = rest.slice(1).join(" ").trim();
|
|
1554
1789
|
if (!value) return { kind: "error", message: `usage: ymmv set ${head} <value>` };
|
|
1555
1790
|
if (value === "-") return { kind: "unset", target: { kind: "curated", key: head } };
|
|
1791
|
+
if (value.length > MAX_VALUE) return valueCapError(value);
|
|
1556
1792
|
return { kind: "set", target: { kind: "curated", key: head, value } };
|
|
1557
1793
|
}
|
|
1558
1794
|
function parseUnset(rest) {
|
|
@@ -1575,40 +1811,69 @@ function parseUnset(rest) {
|
|
|
1575
1811
|
}
|
|
1576
1812
|
function resolveArg(argv) {
|
|
1577
1813
|
const first = argv[0];
|
|
1814
|
+
const rest = argv.slice(1);
|
|
1578
1815
|
if (first === "-h" || first === "--help" || first === "help") return { kind: "help" };
|
|
1579
|
-
if (first === "-V" || first === "-v" || first === "--version"
|
|
1816
|
+
if (first === "-V" || first === "-v" || first === "--version" || first === "version") {
|
|
1817
|
+
return rest.length === 0 ? { kind: "version" } : { kind: "error", message: "usage: ymmv version" };
|
|
1818
|
+
}
|
|
1580
1819
|
if (first === void 0) return { kind: "publish", yes: false };
|
|
1581
|
-
if (first === "-y" || first === "--yes")
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1820
|
+
if (first === "-y" || first === "--yes") {
|
|
1821
|
+
if (rest.length > 0) {
|
|
1822
|
+
const example = rest[0] === "delete" || rest[0] === "publish" ? rest[0] : "publish";
|
|
1823
|
+
return {
|
|
1824
|
+
kind: "error",
|
|
1825
|
+
message: `Put ${first} after the command: ymmv ${example} -y. A bare ymmv -y publishes without prompts.`
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
return { kind: "publish", yes: true };
|
|
1829
|
+
}
|
|
1830
|
+
if (first === "login" || first === "logout") return noArgs(first, rest);
|
|
1831
|
+
if (first === "publish") {
|
|
1832
|
+
return yesOnly("usage: ymmv publish [-y]", rest, (yes) => ({ kind: "publish", yes }));
|
|
1833
|
+
}
|
|
1834
|
+
if (first === "delete") {
|
|
1835
|
+
return yesOnly(
|
|
1836
|
+
"usage: ymmv delete [-y] (deletes your own profile; takes no handle)",
|
|
1837
|
+
rest,
|
|
1838
|
+
(yes) => ({ kind: "delete", yes })
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
if (first === "set") return parseSet(rest);
|
|
1842
|
+
if (first === "unset") return parseUnset(rest);
|
|
1587
1843
|
if (first === "view") {
|
|
1588
|
-
const handle =
|
|
1589
|
-
if (!handle) return { kind: "error", message:
|
|
1844
|
+
const handle = rest[0];
|
|
1845
|
+
if (!handle) return { kind: "error", message: VIEW_USAGE };
|
|
1590
1846
|
if (!isValidHandle(handle)) {
|
|
1591
|
-
return { kind: "error", message: `"${handle}" is not a valid GitHub handle.` };
|
|
1847
|
+
return { kind: "error", message: `"${sanitizeValue(handle)}" is not a valid GitHub handle.` };
|
|
1592
1848
|
}
|
|
1593
1849
|
if (isReserved(handle)) return reservedError(handle);
|
|
1850
|
+
if (rest.length > 1) return { kind: "error", message: VIEW_USAGE };
|
|
1594
1851
|
return { kind: "view", handle };
|
|
1595
1852
|
}
|
|
1596
1853
|
if (first.startsWith("-")) {
|
|
1597
|
-
return {
|
|
1854
|
+
return {
|
|
1855
|
+
kind: "error",
|
|
1856
|
+
message: `Unknown option "${sanitizeValue(first)}". Run \`ymmv help\`.`
|
|
1857
|
+
};
|
|
1598
1858
|
}
|
|
1599
1859
|
if (!isValidHandle(first)) {
|
|
1600
1860
|
return {
|
|
1601
1861
|
kind: "error",
|
|
1602
|
-
message: `"${first}" is not a valid GitHub handle. Run \`ymmv help\`.`
|
|
1862
|
+
message: `"${sanitizeValue(first)}" is not a valid GitHub handle. Run \`ymmv help\`.`
|
|
1603
1863
|
};
|
|
1604
1864
|
}
|
|
1605
|
-
if (isReserved(first)) return reservedError(first);
|
|
1865
|
+
if (isReserved(first)) return reservedError(first, true);
|
|
1866
|
+
if (rest.length > 0) {
|
|
1867
|
+
return { kind: "error", message: `Unexpected arguments after "${first}". Run \`ymmv help\`.` };
|
|
1868
|
+
}
|
|
1606
1869
|
return { kind: "view", handle: first };
|
|
1607
1870
|
}
|
|
1608
|
-
function reservedError(handle) {
|
|
1871
|
+
function reservedError(handle, hintVerbs = false) {
|
|
1872
|
+
const verb = handle.toLowerCase();
|
|
1873
|
+
const hint = hintVerbs && CLI_VERBS.includes(verb) ? ` Did you mean: ymmv ${verb}?` : "";
|
|
1609
1874
|
return {
|
|
1610
1875
|
kind: "error",
|
|
1611
|
-
message: `"${handle}" is a reserved name; it can't have a profile
|
|
1876
|
+
message: `"${handle}" is a reserved name; it can't have a profile.${hint}`
|
|
1612
1877
|
};
|
|
1613
1878
|
}
|
|
1614
1879
|
|
|
@@ -1621,12 +1886,12 @@ ${c.faint}Usage:${c.reset}
|
|
|
1621
1886
|
ymmv <handle> view a profile; logged in, see the diff vs yours
|
|
1622
1887
|
ymmv view <handle> explicit view (same as ymmv <handle>)
|
|
1623
1888
|
ymmv set <key> <value> set one curated key
|
|
1624
|
-
ymmv set --extra "L=V" set a free-form extra
|
|
1889
|
+
ymmv set --extra "L=V" set a free-form extra (-e works too)
|
|
1625
1890
|
ymmv unset <key> remove one curated key (ymmv set <key> - works too)
|
|
1626
1891
|
ymmv unset --extra "L" remove a free-form extra
|
|
1627
|
-
ymmv delete
|
|
1892
|
+
ymmv delete [-y] delete your profile (permanent; -y skips the confirm)
|
|
1628
1893
|
ymmv login | logout GitHub device-flow auth
|
|
1629
|
-
ymmv help |
|
|
1894
|
+
ymmv help | version
|
|
1630
1895
|
|
|
1631
1896
|
${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser, window-manager,
|
|
1632
1897
|
font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
|
|
@@ -1636,7 +1901,7 @@ async function logout() {
|
|
|
1636
1901
|
const otherBase = await peekBase();
|
|
1637
1902
|
console.log(
|
|
1638
1903
|
message(
|
|
1639
|
-
otherBase && otherBase !== BASE ? `Not logged in to ${BASE} (a token for ${otherBase} exists; set YMMV_API to that to log out of it).` : "Not logged in."
|
|
1904
|
+
otherBase && otherBase !== BASE ? `Not logged in to ${BASE} (a token for ${sanitizeValue(otherBase)} exists; set YMMV_API to that to log out of it).` : "Not logged in."
|
|
1640
1905
|
)
|
|
1641
1906
|
);
|
|
1642
1907
|
return;
|
|
@@ -1644,10 +1909,10 @@ async function logout() {
|
|
|
1644
1909
|
let revoked;
|
|
1645
1910
|
try {
|
|
1646
1911
|
revoked = await revokeYmmvToken(stored.token);
|
|
1647
|
-
} catch {
|
|
1912
|
+
} catch (e) {
|
|
1648
1913
|
console.error(
|
|
1649
1914
|
message(
|
|
1650
|
-
"Couldn't reach the server to revoke. Your token is still active. Run `ymmv logout` again when connected."
|
|
1915
|
+
e instanceof NetworkError || isTimeoutError(e) ? "Couldn't reach the server to revoke. Your token is still active. Run `ymmv logout` again when connected." : "The server didn't confirm the revoke. Your token is still active. Run `ymmv logout` again shortly."
|
|
1651
1916
|
)
|
|
1652
1917
|
);
|
|
1653
1918
|
process.exitCode = 1;
|
|
@@ -1675,6 +1940,14 @@ async function interactive(run, yes) {
|
|
|
1675
1940
|
}
|
|
1676
1941
|
async function main(argv) {
|
|
1677
1942
|
const cmd = resolveArg(argv);
|
|
1943
|
+
if (cmd.kind !== "logout") {
|
|
1944
|
+
const problem = baseProblem() ?? credentialEnvProblem();
|
|
1945
|
+
if (problem) {
|
|
1946
|
+
console.error(message(problem));
|
|
1947
|
+
process.exitCode = 1;
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1678
1951
|
switch (cmd.kind) {
|
|
1679
1952
|
case "publish":
|
|
1680
1953
|
await interactive(publish, cmd.yes);
|
|
@@ -1699,6 +1972,13 @@ async function main(argv) {
|
|
|
1699
1972
|
}
|
|
1700
1973
|
case "logout":
|
|
1701
1974
|
await logout();
|
|
1975
|
+
if (process.env.YMMV_TOKEN) {
|
|
1976
|
+
console.error(
|
|
1977
|
+
message(
|
|
1978
|
+
"Note: YMMV_TOKEN is set and still authenticates API calls. Unset it to stop using that token."
|
|
1979
|
+
)
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1702
1982
|
break;
|
|
1703
1983
|
case "help":
|
|
1704
1984
|
console.log(help(palette(colorEnabled())));
|