ymmv-cli 0.6.2 → 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 +450 -108
- 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,10 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
var BASE = (process.env.YMMV_API ?? "https://ymmv.fyi").replace(/\/+$/, "");
|
|
3
|
+
// ../shared/dist/caps.js
|
|
4
|
+
var MAX_EXTRAS = 32;
|
|
5
|
+
var MAX_LABEL = 64;
|
|
6
|
+
var MAX_VALUE = 256;
|
|
8
7
|
|
|
9
8
|
// ../shared/dist/keys.js
|
|
10
9
|
var CURATED_KEYS = [
|
|
@@ -282,7 +281,8 @@ function parseProfile(raw) {
|
|
|
282
281
|
if (!isRecord(raw))
|
|
283
282
|
throw new ProfileParseError("profile is not an object");
|
|
284
283
|
if (raw.schema_version !== SCHEMA_VERSION) {
|
|
285
|
-
|
|
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).`);
|
|
286
286
|
}
|
|
287
287
|
if (typeof raw.handle !== "string")
|
|
288
288
|
throw new ProfileParseError("handle is not a string");
|
|
@@ -340,9 +340,22 @@ function parseProfile(raw) {
|
|
|
340
340
|
|
|
341
341
|
// ../shared/dist/reserved.js
|
|
342
342
|
var RESERVED_ROUTES = ["404", "api", "login", "logout"];
|
|
343
|
-
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
|
+
];
|
|
344
354
|
var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
|
|
345
355
|
var RESERVED_SET = new Set(RESERVED);
|
|
356
|
+
function isReserved(handle) {
|
|
357
|
+
return RESERVED_SET.has(handle.toLowerCase());
|
|
358
|
+
}
|
|
346
359
|
var HANDLE_RE = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$/;
|
|
347
360
|
function isValidHandle(handle) {
|
|
348
361
|
return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
|
|
@@ -385,7 +398,9 @@ function sanitizeValue(value) {
|
|
|
385
398
|
}
|
|
386
399
|
function useColor(env, isTTY) {
|
|
387
400
|
if (env.NO_COLOR !== void 0) return false;
|
|
388
|
-
if (env.FORCE_COLOR !== void 0)
|
|
401
|
+
if (env.FORCE_COLOR !== void 0) {
|
|
402
|
+
return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
|
|
403
|
+
}
|
|
389
404
|
if (env.TERM === "dumb") return false;
|
|
390
405
|
return isTTY;
|
|
391
406
|
}
|
|
@@ -445,13 +460,15 @@ function renderProfile(profile, opts) {
|
|
|
445
460
|
const val = (v) => isHttpUrl(v) ? link(v, opts.color) : v;
|
|
446
461
|
const lines = [
|
|
447
462
|
"",
|
|
448
|
-
` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}
|
|
449
|
-
""
|
|
463
|
+
` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`
|
|
450
464
|
];
|
|
451
|
-
|
|
452
|
-
lines.push(
|
|
453
|
-
|
|
454
|
-
|
|
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
|
+
}
|
|
455
472
|
}
|
|
456
473
|
if (extras.length) {
|
|
457
474
|
lines.push("");
|
|
@@ -526,6 +543,11 @@ function notFound(handle, color, base) {
|
|
|
526
543
|
}
|
|
527
544
|
|
|
528
545
|
// src/http.ts
|
|
546
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
547
|
+
var TIMEOUT_TEXT = "request timed out";
|
|
548
|
+
function isTimeoutError(err) {
|
|
549
|
+
return err instanceof Error && (err.name === "TimeoutError" || err.cause instanceof Error && err.cause.name === "TimeoutError");
|
|
550
|
+
}
|
|
529
551
|
function causeText(err) {
|
|
530
552
|
const pick = (e) => {
|
|
531
553
|
if (e instanceof AggregateError) {
|
|
@@ -536,6 +558,7 @@ function causeText(err) {
|
|
|
536
558
|
}
|
|
537
559
|
return e.message;
|
|
538
560
|
};
|
|
561
|
+
if (isTimeoutError(err)) return TIMEOUT_TEXT;
|
|
539
562
|
let text;
|
|
540
563
|
if (err instanceof Error) {
|
|
541
564
|
const fromCause = err.cause instanceof Error ? pick(err.cause) : "";
|
|
@@ -549,17 +572,119 @@ function wireText(text) {
|
|
|
549
572
|
const clean = sanitizeValue(String(text));
|
|
550
573
|
return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
|
|
551
574
|
}
|
|
575
|
+
var INVISIBLE_RE = new RegExp("\\p{Default_Ignorable_Code_Point}", "gu");
|
|
576
|
+
async function wireBody(res) {
|
|
577
|
+
try {
|
|
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;
|
|
589
|
+
if (typeof body?.message === "string") {
|
|
590
|
+
const clean = wireText(body.message).trim();
|
|
591
|
+
if (clean.replace(INVISIBLE_RE, "").trim()) out.message = clean;
|
|
592
|
+
}
|
|
593
|
+
return out;
|
|
594
|
+
} catch {
|
|
595
|
+
return {};
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async function serverMessage(res) {
|
|
599
|
+
return wireErrorBody(await wireBody(res)).message;
|
|
600
|
+
}
|
|
601
|
+
function withRetryHint(msg, res) {
|
|
602
|
+
const retry = res.headers.get("retry-after");
|
|
603
|
+
return retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg;
|
|
604
|
+
}
|
|
605
|
+
function displayError(err) {
|
|
606
|
+
if (err instanceof Error && err.name === "TimeoutError") return TIMEOUT_TEXT;
|
|
607
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
608
|
+
return text.split(/\r?\n/).map((line) => sanitizeValue(line)).join("\n");
|
|
609
|
+
}
|
|
610
|
+
var NetworkError = class extends Error {
|
|
611
|
+
constructor(message2, options) {
|
|
612
|
+
super(message2, options);
|
|
613
|
+
this.name = "NetworkError";
|
|
614
|
+
}
|
|
615
|
+
};
|
|
552
616
|
async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
553
617
|
try {
|
|
554
|
-
return await fetchFn(url,
|
|
618
|
+
return await fetchFn(url, {
|
|
619
|
+
...init,
|
|
620
|
+
signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
621
|
+
});
|
|
555
622
|
} catch (err) {
|
|
556
|
-
throw new
|
|
623
|
+
throw new NetworkError(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
|
|
557
624
|
cause: err
|
|
558
625
|
});
|
|
559
626
|
}
|
|
560
627
|
}
|
|
561
628
|
|
|
629
|
+
// src/index.ts
|
|
630
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
631
|
+
|
|
632
|
+
// src/config.ts
|
|
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
|
+
}
|
|
678
|
+
|
|
562
679
|
// src/auth-http.ts
|
|
680
|
+
async function bodyJson(res) {
|
|
681
|
+
try {
|
|
682
|
+
return await res.json();
|
|
683
|
+
} catch (err) {
|
|
684
|
+
if (isTimeoutError(err)) throw err;
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
563
688
|
async function mintYmmvToken(accessToken) {
|
|
564
689
|
const res = await safeFetch(
|
|
565
690
|
`${BASE}/api/v1/auth/token`,
|
|
@@ -575,20 +700,32 @@ async function mintYmmvToken(accessToken) {
|
|
|
575
700
|
BASE
|
|
576
701
|
);
|
|
577
702
|
if (!res.ok) {
|
|
578
|
-
const body = await res.json().catch(() => ({}));
|
|
579
703
|
if (res.status === 503) {
|
|
580
704
|
throw new Error(
|
|
581
|
-
|
|
705
|
+
await serverMessage(res) ?? "GitHub is unavailable. Run `ymmv login` again shortly."
|
|
582
706
|
);
|
|
583
707
|
}
|
|
584
708
|
if (res.status === 429) {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
709
|
+
throw new Error(
|
|
710
|
+
withRetryHint(
|
|
711
|
+
await serverMessage(res) ?? "Too many login attempts. Slow down and try again shortly",
|
|
712
|
+
res
|
|
713
|
+
)
|
|
714
|
+
);
|
|
588
715
|
}
|
|
589
|
-
|
|
716
|
+
const body = await bodyJson(res) ?? {};
|
|
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());
|
|
590
727
|
}
|
|
591
|
-
const data = await res
|
|
728
|
+
const data = await bodyJson(res);
|
|
592
729
|
if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
|
|
593
730
|
throw new Error(
|
|
594
731
|
`Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`
|
|
@@ -597,16 +734,23 @@ async function mintYmmvToken(accessToken) {
|
|
|
597
734
|
return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
|
|
598
735
|
}
|
|
599
736
|
async function revokeYmmvToken(token) {
|
|
600
|
-
const res = await
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
737
|
+
const res = await safeFetch(
|
|
738
|
+
`${BASE}/api/v1/auth/logout`,
|
|
739
|
+
{
|
|
740
|
+
method: "POST",
|
|
741
|
+
headers: { authorization: `Bearer ${token}` },
|
|
742
|
+
// Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete
|
|
743
|
+
// the local file while the server token stays live). Same guard as mint + publish/delete.
|
|
744
|
+
redirect: "manual"
|
|
745
|
+
},
|
|
746
|
+
BASE
|
|
747
|
+
);
|
|
607
748
|
if (!res.ok) throw new Error(`logout failed: ${res.status}`);
|
|
608
|
-
const body = await res
|
|
609
|
-
|
|
749
|
+
const body = await bodyJson(res);
|
|
750
|
+
if (!body || typeof body.revoked !== "boolean") {
|
|
751
|
+
throw new Error("logout failed: unexpected response");
|
|
752
|
+
}
|
|
753
|
+
return body.revoked;
|
|
610
754
|
}
|
|
611
755
|
|
|
612
756
|
// src/commands.ts
|
|
@@ -638,38 +782,49 @@ async function saveToken(data) {
|
|
|
638
782
|
throw e;
|
|
639
783
|
}
|
|
640
784
|
}
|
|
641
|
-
async function
|
|
642
|
-
let raw;
|
|
785
|
+
async function readTokenFile() {
|
|
643
786
|
try {
|
|
644
|
-
|
|
787
|
+
const parsed = JSON.parse(await readFile(tokenFilePath(), "utf8"));
|
|
788
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
645
789
|
} catch {
|
|
646
790
|
return null;
|
|
647
791
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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") {
|
|
652
796
|
return null;
|
|
653
797
|
}
|
|
654
|
-
if (parsed.base !== BASE || typeof parsed.token !== "string") return null;
|
|
655
798
|
return parsed;
|
|
656
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
|
+
}
|
|
657
812
|
async function deleteToken() {
|
|
658
813
|
await rm(tokenFilePath(), { force: true });
|
|
659
814
|
}
|
|
660
815
|
async function peekBase() {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
return typeof parsed.base === "string" ? parsed.base : null;
|
|
664
|
-
} catch {
|
|
665
|
-
return null;
|
|
666
|
-
}
|
|
816
|
+
const parsed = await readTokenFile();
|
|
817
|
+
return typeof parsed?.base === "string" ? parsed.base : null;
|
|
667
818
|
}
|
|
668
819
|
|
|
669
820
|
// src/device-flow.ts
|
|
670
821
|
var DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
671
822
|
var TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
672
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
|
+
}
|
|
673
828
|
async function requestDeviceCode(deps = {}) {
|
|
674
829
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
675
830
|
const res = await safeFetch(
|
|
@@ -685,8 +840,14 @@ async function requestDeviceCode(deps = {}) {
|
|
|
685
840
|
if (!res.ok) {
|
|
686
841
|
throw new Error(`device code request failed: ${res.status} ${wireText(await res.text())}`);
|
|
687
842
|
}
|
|
688
|
-
const data = await res.json().catch(() =>
|
|
689
|
-
|
|
843
|
+
const data = await res.json().catch((err) => {
|
|
844
|
+
if (isTimeoutError(err)) throw err;
|
|
845
|
+
return null;
|
|
846
|
+
});
|
|
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)) {
|
|
690
851
|
throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
|
|
691
852
|
}
|
|
692
853
|
return data;
|
|
@@ -695,13 +856,14 @@ async function pollForToken(dc, deps = {}) {
|
|
|
695
856
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
696
857
|
const sleep = deps.sleep ?? realSleep;
|
|
697
858
|
const now = deps.now ?? Date.now;
|
|
698
|
-
let interval = dc.interval
|
|
859
|
+
let interval = usableInterval(dc.interval) ? dc.interval : DEFAULT_POLL_INTERVAL_S;
|
|
699
860
|
const deadline = now() + dc.expires_in * 1e3;
|
|
700
861
|
let transientFailures = 0;
|
|
701
862
|
let lastCause = "";
|
|
702
863
|
const MAX_TRANSIENT_FAILURES = 5;
|
|
703
864
|
while (now() < deadline) {
|
|
704
865
|
await sleep(interval * 1e3);
|
|
866
|
+
if (now() >= deadline) break;
|
|
705
867
|
let res;
|
|
706
868
|
try {
|
|
707
869
|
res = await doFetch(TOKEN_URL, {
|
|
@@ -711,7 +873,12 @@ async function pollForToken(dc, deps = {}) {
|
|
|
711
873
|
client_id: GITHUB_CLIENT_ID,
|
|
712
874
|
device_code: dc.device_code,
|
|
713
875
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
714
|
-
})
|
|
876
|
+
}),
|
|
877
|
+
// GitHub answers this endpoint immediately — slow_down pacing lives in the sleep above,
|
|
878
|
+
// never inside a held request — so the per-request timeout can't cut pacing short. It only
|
|
879
|
+
// turns a HUNG poll into a TimeoutError feeding the transient counter (previously a hang
|
|
880
|
+
// here stalled the login forever: the deadline is only checked between iterations).
|
|
881
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
715
882
|
});
|
|
716
883
|
} catch (err) {
|
|
717
884
|
lastCause = causeText(err);
|
|
@@ -720,9 +887,9 @@ async function pollForToken(dc, deps = {}) {
|
|
|
720
887
|
if (res?.ok) {
|
|
721
888
|
try {
|
|
722
889
|
tok = await res.json();
|
|
723
|
-
} catch {
|
|
890
|
+
} catch (err) {
|
|
724
891
|
tok = void 0;
|
|
725
|
-
lastCause = "unexpected response body";
|
|
892
|
+
lastCause = isTimeoutError(err) ? causeText(err) : "unexpected response body";
|
|
726
893
|
}
|
|
727
894
|
} else if (res) {
|
|
728
895
|
lastCause = `HTTP ${res.status}`;
|
|
@@ -741,7 +908,7 @@ async function pollForToken(dc, deps = {}) {
|
|
|
741
908
|
case "authorization_pending":
|
|
742
909
|
break;
|
|
743
910
|
case "slow_down":
|
|
744
|
-
interval = Math.max(tok.interval
|
|
911
|
+
interval = usableInterval(tok.interval) ? Math.max(tok.interval, interval + DEFAULT_POLL_INTERVAL_S) : interval + DEFAULT_POLL_INTERVAL_S;
|
|
745
912
|
break;
|
|
746
913
|
case "access_denied":
|
|
747
914
|
throw new Error("Authorization denied. Run `ymmv login` to try again.");
|
|
@@ -759,9 +926,24 @@ async function login(deps = {}) {
|
|
|
759
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)."
|
|
760
927
|
);
|
|
761
928
|
}
|
|
762
|
-
|
|
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();
|
|
763
937
|
const color = colorEnabled();
|
|
764
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);
|
|
765
947
|
const verifyUri = /^https:\/\/github\.com\//.test(dc.verification_uri) ? link(dc.verification_uri, color) : sanitizeValue(dc.verification_uri);
|
|
766
948
|
console.log(
|
|
767
949
|
message(
|
|
@@ -771,6 +953,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
771
953
|
);
|
|
772
954
|
const accessToken = await pollForToken(dc, deps);
|
|
773
955
|
const { token, handle } = await mintYmmvToken(accessToken);
|
|
956
|
+
const replaced = await peekCredential();
|
|
774
957
|
try {
|
|
775
958
|
await saveToken({ token, handle });
|
|
776
959
|
} catch (e) {
|
|
@@ -778,6 +961,13 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
778
961
|
});
|
|
779
962
|
throw e;
|
|
780
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
|
+
}
|
|
781
971
|
console.log(
|
|
782
972
|
message(
|
|
783
973
|
handle ? `Logged in as ${handle}.` : "Logged in. No handle bound (your GitHub username is a reserved word)."
|
|
@@ -786,21 +976,21 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
786
976
|
}
|
|
787
977
|
|
|
788
978
|
// src/api.ts
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const body = await res.json();
|
|
794
|
-
if (typeof body?.message === "string" && body.message) msg = wireText(body.message);
|
|
795
|
-
} catch {
|
|
979
|
+
var PublishRefusal = class extends Error {
|
|
980
|
+
constructor(msg) {
|
|
981
|
+
super(msg);
|
|
982
|
+
this.name = "PublishRefusal";
|
|
796
983
|
}
|
|
797
|
-
|
|
984
|
+
};
|
|
985
|
+
var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (revoked or expired).";
|
|
986
|
+
async function rateLimitMessage(res) {
|
|
987
|
+
return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
|
|
798
988
|
}
|
|
799
989
|
async function ensureLogin() {
|
|
800
|
-
const existing = await
|
|
990
|
+
const existing = await loadCredential();
|
|
801
991
|
if (existing) return existing;
|
|
802
992
|
await login();
|
|
803
|
-
const fresh = await
|
|
993
|
+
const fresh = await loadCredential();
|
|
804
994
|
if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
|
|
805
995
|
return fresh;
|
|
806
996
|
}
|
|
@@ -820,35 +1010,60 @@ async function publishProfile(profile) {
|
|
|
820
1010
|
);
|
|
821
1011
|
let cred = await ensureLogin();
|
|
822
1012
|
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
823
|
-
throw new
|
|
1013
|
+
throw new PublishRefusal(
|
|
824
1014
|
"The stored login changed while this command was running. Re-run it under the current account."
|
|
825
1015
|
);
|
|
826
1016
|
}
|
|
827
1017
|
let res = await send(cred);
|
|
828
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
|
+
}
|
|
829
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
|
+
);
|
|
830
1030
|
if (was401) await deleteToken();
|
|
831
1031
|
await login();
|
|
832
1032
|
cred = await ensureLogin();
|
|
833
1033
|
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
834
1034
|
const bound = sanitizeValue(cred.handle ?? "");
|
|
835
|
-
throw new
|
|
1035
|
+
throw new PublishRefusal(
|
|
836
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.`
|
|
837
1037
|
);
|
|
838
1038
|
}
|
|
839
1039
|
res = await send(cred);
|
|
840
|
-
if (res.status === 401) throw new
|
|
1040
|
+
if (res.status === 401) throw new PublishRefusal("Authentication failed. Run `ymmv login`.");
|
|
841
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
|
+
}
|
|
842
1048
|
throw new Error(
|
|
843
|
-
"that handle is taken by another account (your GitHub handle may have been reused)."
|
|
1049
|
+
srvMsg ?? "that handle is taken by another account (your GitHub handle may have been reused)."
|
|
844
1050
|
);
|
|
845
1051
|
}
|
|
846
1052
|
}
|
|
847
1053
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
848
1054
|
if (!res.ok) {
|
|
849
|
-
|
|
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 {
|
|
850
1066
|
}
|
|
851
|
-
const data = await res.json();
|
|
852
1067
|
const shown = typeof data.handle === "string" ? sanitizeValue(data.handle) : profile.handle;
|
|
853
1068
|
return { handle: shown, url: `${BASE}/${shown}` };
|
|
854
1069
|
}
|
|
@@ -860,8 +1075,7 @@ async function fetchProfileJson(handle) {
|
|
|
860
1075
|
}
|
|
861
1076
|
return parseProfile(await res.json());
|
|
862
1077
|
}
|
|
863
|
-
async function deleteProfile() {
|
|
864
|
-
const cred = await ensureLogin();
|
|
1078
|
+
async function deleteProfile(cred) {
|
|
865
1079
|
const res = await safeFetch(
|
|
866
1080
|
`${BASE}/api/v1/profile`,
|
|
867
1081
|
{
|
|
@@ -872,11 +1086,14 @@ async function deleteProfile() {
|
|
|
872
1086
|
BASE
|
|
873
1087
|
);
|
|
874
1088
|
if (res.status === 401) {
|
|
875
|
-
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
|
+
);
|
|
876
1092
|
}
|
|
877
1093
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
878
1094
|
if (!res.ok) {
|
|
879
|
-
|
|
1095
|
+
const raw = await wireBody(res);
|
|
1096
|
+
throw new Error(wireErrorBody(raw).message ?? `delete failed: ${res.status} ${wireText(raw)}`);
|
|
880
1097
|
}
|
|
881
1098
|
}
|
|
882
1099
|
|
|
@@ -1245,7 +1462,7 @@ function requireHandle(cred) {
|
|
|
1245
1462
|
if (cred.handle) return cred.handle;
|
|
1246
1463
|
console.error(
|
|
1247
1464
|
message(
|
|
1248
|
-
"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."
|
|
1249
1466
|
)
|
|
1250
1467
|
);
|
|
1251
1468
|
process.exitCode = 1;
|
|
@@ -1280,9 +1497,21 @@ async function promptEntries(defaults, prompter) {
|
|
|
1280
1497
|
console.log(message(`${c.faint}Enter to keep, "-" to clear${c.reset}`));
|
|
1281
1498
|
const chosen = /* @__PURE__ */ new Map();
|
|
1282
1499
|
for (const key of CURATED_KEYS) {
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
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
|
+
}
|
|
1286
1515
|
}
|
|
1287
1516
|
return chosen;
|
|
1288
1517
|
}
|
|
@@ -1337,6 +1566,16 @@ async function publish(io) {
|
|
|
1337
1566
|
let values = defaults;
|
|
1338
1567
|
const assemble = () => [...entriesFromMap(values), ...carried];
|
|
1339
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
|
+
}
|
|
1340
1579
|
const entries = assemble();
|
|
1341
1580
|
showCard(entries);
|
|
1342
1581
|
printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
|
|
@@ -1354,8 +1593,20 @@ async function publish(io) {
|
|
|
1354
1593
|
"Y/n/e=edit"
|
|
1355
1594
|
);
|
|
1356
1595
|
if (ans === "y") {
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
+
}
|
|
1359
1610
|
}
|
|
1360
1611
|
if (ans === "n") {
|
|
1361
1612
|
console.log(message("Aborted. Nothing published."));
|
|
@@ -1382,7 +1633,11 @@ async function view(handle) {
|
|
|
1382
1633
|
}
|
|
1383
1634
|
const cred = await loadToken();
|
|
1384
1635
|
if (cred?.handle) {
|
|
1385
|
-
|
|
1636
|
+
let mineFailed = false;
|
|
1637
|
+
const mine = await fetchProfileJson(cred.handle).catch(() => {
|
|
1638
|
+
mineFailed = true;
|
|
1639
|
+
return null;
|
|
1640
|
+
});
|
|
1386
1641
|
if (mine && mine.handle.toLowerCase() !== theirs.handle.toLowerCase()) {
|
|
1387
1642
|
console.log(
|
|
1388
1643
|
renderDiff(diff(mine, theirs), { color: c, theirsLabel: theirs.handle, mineLabel: "you" })
|
|
@@ -1391,7 +1646,12 @@ async function view(handle) {
|
|
|
1391
1646
|
}
|
|
1392
1647
|
if (!mine) {
|
|
1393
1648
|
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
1394
|
-
|
|
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
|
+
}
|
|
1395
1655
|
return;
|
|
1396
1656
|
}
|
|
1397
1657
|
}
|
|
@@ -1404,6 +1664,15 @@ async function runSet(target) {
|
|
|
1404
1664
|
const existing = await fetchProfileJson(handle);
|
|
1405
1665
|
assertHandleUnchanged(existing, handle);
|
|
1406
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
|
+
}
|
|
1407
1676
|
const res = await publishProfile(newProfile(handle, entries, extras));
|
|
1408
1677
|
const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
|
|
1409
1678
|
console.log(message(`${line}${pagePointer(res.handle)}`));
|
|
@@ -1433,7 +1702,7 @@ async function runUnset(target) {
|
|
|
1433
1702
|
}
|
|
1434
1703
|
async function runDelete(io) {
|
|
1435
1704
|
const cred = await ensureLogin();
|
|
1436
|
-
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";
|
|
1437
1706
|
if (!io.yes) {
|
|
1438
1707
|
if (!io.interactive || !io.prompter) {
|
|
1439
1708
|
console.error(
|
|
@@ -1461,8 +1730,8 @@ ${message("Cancelled. Nothing deleted.")}`);
|
|
|
1461
1730
|
return;
|
|
1462
1731
|
}
|
|
1463
1732
|
}
|
|
1464
|
-
await deleteProfile();
|
|
1465
|
-
await deleteToken();
|
|
1733
|
+
await deleteProfile(cred);
|
|
1734
|
+
if (cred.source === "file") await deleteToken();
|
|
1466
1735
|
console.log(message(`Deleted ${target}. Run \`ymmv\` to publish again.`));
|
|
1467
1736
|
}
|
|
1468
1737
|
|
|
@@ -1472,15 +1741,33 @@ var UNSET_EXTRA = 'ymmv unset --extra "Label"';
|
|
|
1472
1741
|
var SET_USAGE = `usage: ymmv set <key> <value> | ${SET_EXTRA}`;
|
|
1473
1742
|
var EXTRA_USAGE = `usage: ${SET_EXTRA}`;
|
|
1474
1743
|
var UNSET_USAGE = `usage: ymmv unset <key> | ${UNSET_EXTRA}`;
|
|
1744
|
+
var VIEW_USAGE = "usage: ymmv view <handle>";
|
|
1475
1745
|
function invalidKeyError(head, hint) {
|
|
1476
1746
|
return {
|
|
1477
1747
|
kind: "error",
|
|
1478
|
-
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(", ")}.
|
|
1479
1749
|
For anything else, use: ${hint}.`
|
|
1480
1750
|
};
|
|
1481
1751
|
}
|
|
1482
|
-
function
|
|
1483
|
-
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
|
+
};
|
|
1484
1771
|
}
|
|
1485
1772
|
function parseSet(rest) {
|
|
1486
1773
|
const head = rest[0];
|
|
@@ -1492,6 +1779,8 @@ function parseSet(rest) {
|
|
|
1492
1779
|
const value2 = spec.slice(eq + 1).trim();
|
|
1493
1780
|
if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
|
|
1494
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);
|
|
1495
1784
|
return { kind: "set", target: { kind: "extra", label, value: value2 } };
|
|
1496
1785
|
}
|
|
1497
1786
|
if (!head) return { kind: "error", message: SET_USAGE };
|
|
@@ -1499,6 +1788,7 @@ function parseSet(rest) {
|
|
|
1499
1788
|
const value = rest.slice(1).join(" ").trim();
|
|
1500
1789
|
if (!value) return { kind: "error", message: `usage: ymmv set ${head} <value>` };
|
|
1501
1790
|
if (value === "-") return { kind: "unset", target: { kind: "curated", key: head } };
|
|
1791
|
+
if (value.length > MAX_VALUE) return valueCapError(value);
|
|
1502
1792
|
return { kind: "set", target: { kind: "curated", key: head, value } };
|
|
1503
1793
|
}
|
|
1504
1794
|
function parseUnset(rest) {
|
|
@@ -1521,34 +1811,71 @@ function parseUnset(rest) {
|
|
|
1521
1811
|
}
|
|
1522
1812
|
function resolveArg(argv) {
|
|
1523
1813
|
const first = argv[0];
|
|
1814
|
+
const rest = argv.slice(1);
|
|
1524
1815
|
if (first === "-h" || first === "--help" || first === "help") return { kind: "help" };
|
|
1525
|
-
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
|
+
}
|
|
1526
1819
|
if (first === void 0) return { kind: "publish", yes: false };
|
|
1527
|
-
if (first === "-y" || first === "--yes")
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
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);
|
|
1533
1843
|
if (first === "view") {
|
|
1534
|
-
const handle =
|
|
1535
|
-
if (!handle) return { kind: "error", message:
|
|
1844
|
+
const handle = rest[0];
|
|
1845
|
+
if (!handle) return { kind: "error", message: VIEW_USAGE };
|
|
1536
1846
|
if (!isValidHandle(handle)) {
|
|
1537
|
-
return { kind: "error", message: `"${handle}" is not a valid GitHub handle.` };
|
|
1847
|
+
return { kind: "error", message: `"${sanitizeValue(handle)}" is not a valid GitHub handle.` };
|
|
1538
1848
|
}
|
|
1849
|
+
if (isReserved(handle)) return reservedError(handle);
|
|
1850
|
+
if (rest.length > 1) return { kind: "error", message: VIEW_USAGE };
|
|
1539
1851
|
return { kind: "view", handle };
|
|
1540
1852
|
}
|
|
1541
1853
|
if (first.startsWith("-")) {
|
|
1542
|
-
return {
|
|
1854
|
+
return {
|
|
1855
|
+
kind: "error",
|
|
1856
|
+
message: `Unknown option "${sanitizeValue(first)}". Run \`ymmv help\`.`
|
|
1857
|
+
};
|
|
1543
1858
|
}
|
|
1544
1859
|
if (!isValidHandle(first)) {
|
|
1545
1860
|
return {
|
|
1546
1861
|
kind: "error",
|
|
1547
|
-
message: `"${first}" is not a valid GitHub handle. Run \`ymmv help\`.`
|
|
1862
|
+
message: `"${sanitizeValue(first)}" is not a valid GitHub handle. Run \`ymmv help\`.`
|
|
1548
1863
|
};
|
|
1549
1864
|
}
|
|
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
|
+
}
|
|
1550
1869
|
return { kind: "view", handle: first };
|
|
1551
1870
|
}
|
|
1871
|
+
function reservedError(handle, hintVerbs = false) {
|
|
1872
|
+
const verb = handle.toLowerCase();
|
|
1873
|
+
const hint = hintVerbs && CLI_VERBS.includes(verb) ? ` Did you mean: ymmv ${verb}?` : "";
|
|
1874
|
+
return {
|
|
1875
|
+
kind: "error",
|
|
1876
|
+
message: `"${handle}" is a reserved name; it can't have a profile.${hint}`
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1552
1879
|
|
|
1553
1880
|
// src/index.ts
|
|
1554
1881
|
var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
|
|
@@ -1557,14 +1884,14 @@ ${c.faint}Usage:${c.reset}
|
|
|
1557
1884
|
ymmv detect your stack, confirm, and publish your profile
|
|
1558
1885
|
ymmv -y publish without prompts (required when stdin isn't a TTY)
|
|
1559
1886
|
ymmv <handle> view a profile; logged in, see the diff vs yours
|
|
1560
|
-
ymmv view <handle> explicit view (
|
|
1887
|
+
ymmv view <handle> explicit view (same as ymmv <handle>)
|
|
1561
1888
|
ymmv set <key> <value> set one curated key
|
|
1562
|
-
ymmv set --extra "L=V" set a free-form extra
|
|
1889
|
+
ymmv set --extra "L=V" set a free-form extra (-e works too)
|
|
1563
1890
|
ymmv unset <key> remove one curated key (ymmv set <key> - works too)
|
|
1564
1891
|
ymmv unset --extra "L" remove a free-form extra
|
|
1565
|
-
ymmv delete
|
|
1892
|
+
ymmv delete [-y] delete your profile (permanent; -y skips the confirm)
|
|
1566
1893
|
ymmv login | logout GitHub device-flow auth
|
|
1567
|
-
ymmv help |
|
|
1894
|
+
ymmv help | version
|
|
1568
1895
|
|
|
1569
1896
|
${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser, window-manager,
|
|
1570
1897
|
font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
|
|
@@ -1574,7 +1901,7 @@ async function logout() {
|
|
|
1574
1901
|
const otherBase = await peekBase();
|
|
1575
1902
|
console.log(
|
|
1576
1903
|
message(
|
|
1577
|
-
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."
|
|
1578
1905
|
)
|
|
1579
1906
|
);
|
|
1580
1907
|
return;
|
|
@@ -1582,10 +1909,10 @@ async function logout() {
|
|
|
1582
1909
|
let revoked;
|
|
1583
1910
|
try {
|
|
1584
1911
|
revoked = await revokeYmmvToken(stored.token);
|
|
1585
|
-
} catch {
|
|
1912
|
+
} catch (e) {
|
|
1586
1913
|
console.error(
|
|
1587
1914
|
message(
|
|
1588
|
-
"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."
|
|
1589
1916
|
)
|
|
1590
1917
|
);
|
|
1591
1918
|
process.exitCode = 1;
|
|
@@ -1613,6 +1940,14 @@ async function interactive(run, yes) {
|
|
|
1613
1940
|
}
|
|
1614
1941
|
async function main(argv) {
|
|
1615
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
|
+
}
|
|
1616
1951
|
switch (cmd.kind) {
|
|
1617
1952
|
case "publish":
|
|
1618
1953
|
await interactive(publish, cmd.yes);
|
|
@@ -1637,6 +1972,13 @@ async function main(argv) {
|
|
|
1637
1972
|
}
|
|
1638
1973
|
case "logout":
|
|
1639
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
|
+
}
|
|
1640
1982
|
break;
|
|
1641
1983
|
case "help":
|
|
1642
1984
|
console.log(help(palette(colorEnabled())));
|
|
@@ -1653,7 +1995,7 @@ async function main(argv) {
|
|
|
1653
1995
|
|
|
1654
1996
|
// src/cli.ts
|
|
1655
1997
|
main(process.argv.slice(2)).catch((err) => {
|
|
1656
|
-
console.error(message(
|
|
1998
|
+
console.error(message(displayError(err)));
|
|
1657
1999
|
process.exitCode = 1;
|
|
1658
2000
|
}).finally(() => {
|
|
1659
2001
|
(process.exitCode ? process.stderr : process.stdout).write("\n");
|