ymmv-cli 0.7.0 → 0.9.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 +59 -10
- package/dist/cli.js +669 -95
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,10 +9,13 @@ clean page at `ymmv.fyi/<handle>` in about 10 seconds. See a live one:
|
|
|
9
9
|

|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npx ymmv-cli # detect your stack, confirm, go live at ymmv.fyi/<you>
|
|
13
|
-
npx ymmv-cli bardisty # view someone's stack in the terminal
|
|
12
|
+
npx ymmv-cli@latest # detect your stack, confirm, go live at ymmv.fyi/<you>
|
|
13
|
+
npx ymmv-cli@latest bardisty # view someone's stack in the terminal
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
(`@latest` makes npx look for the newest release; without a version spec, npx can pin you
|
|
17
|
+
to whatever it downloaded first.)
|
|
18
|
+
|
|
16
19
|
Viewing someone while you're logged in diffs their stack against yours:
|
|
17
20
|
|
|
18
21
|
```
|
|
@@ -33,6 +36,9 @@ Install once for the short `ymmv` command:
|
|
|
33
36
|
npm i -g ymmv-cli
|
|
34
37
|
```
|
|
35
38
|
|
|
39
|
+
When a newer release exists, the CLI mentions it after a command (interactive terminals only)
|
|
40
|
+
and `ymmv update` upgrades a global install in place.
|
|
41
|
+
|
|
36
42
|
First run includes a one-time GitHub sign-in. Works on macOS, Linux, Windows,
|
|
37
43
|
and WSL. Nothing is published until you confirm (detection only pre-fills), and
|
|
38
44
|
`ymmv delete` removes everything. Releases are published from GitHub Actions
|
|
@@ -40,18 +46,61 @@ via npm Trusted Publishing, with provenance.
|
|
|
40
46
|
|
|
41
47
|
## Commands
|
|
42
48
|
|
|
43
|
-
- `ymmv` detects, confirms, and publishes (re-run any time to update)
|
|
49
|
+
- `ymmv` detects, confirms, and publishes (re-run any time to update; `ymmv publish` is the same command)
|
|
44
50
|
- `ymmv <handle>` views a profile, or diffs it against yours when you're logged in
|
|
45
51
|
- `ymmv set editor Neovim` changes one value
|
|
46
|
-
- `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own
|
|
52
|
+
- `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own (`-e` works too)
|
|
47
53
|
- `ymmv unset editor` removes one value (`ymmv set editor -` works too); `ymmv unset --extra "Keyboard"` removes an extra
|
|
48
|
-
- `ymmv delete` removes your profile
|
|
54
|
+
- `ymmv delete` removes your profile (`ymmv delete -y` skips the confirm, for scripts)
|
|
49
55
|
- `ymmv login` / `ymmv logout` sign in / out
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
- `ymmv update` updates the CLI to the latest release (runs the matching upgrade for npm, pnpm,
|
|
57
|
+
and bun global installs; via npx it prints the invocation to use instead; anything else gets
|
|
58
|
+
the commands to run by hand)
|
|
59
|
+
- `ymmv version` prints the CLI version (and notes a newer release when one is known)
|
|
60
|
+
|
|
61
|
+
Values are capped at 256 characters and extra labels at 64; a profile holds up
|
|
62
|
+
to 32 extras.
|
|
63
|
+
|
|
64
|
+
Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`. Full contract
|
|
65
|
+
(shape, statuses, caching, CORS):
|
|
66
|
+
<https://github.com/ymmv-fyi/ymmv/blob/main/docs/api.md>.
|
|
67
|
+
|
|
68
|
+
## Environment variables
|
|
69
|
+
|
|
70
|
+
- `NO_COLOR` disables color output (and `FORCE_COLOR=0`/`false` force-disables it).
|
|
71
|
+
- `YMMV_API` points the CLI at a different Worker (development/staging). Bare origin only.
|
|
72
|
+
- `YMMV_TOKEN` authenticates without a browser (CI and scripts, below). Takes precedence over
|
|
73
|
+
the stored login and is read-only: the CLI never writes, revokes, or deletes it, and
|
|
74
|
+
`ymmv login` / `ymmv logout` keep acting on the stored login. Viewing (`ymmv <handle>`) also
|
|
75
|
+
keeps using the stored login for the you-side of a diff. The token is sent to the server
|
|
76
|
+
`YMMV_API` selects, so set the two together.
|
|
77
|
+
- `YMMV_HANDLE` names the GitHub username `YMMV_TOKEN` belongs to. Required for `ymmv -y` and
|
|
78
|
+
`ymmv set`/`unset` under an env token (there is no server lookup for it); ignored without
|
|
79
|
+
`YMMV_TOKEN`.
|
|
80
|
+
- `YMMV_NO_UPDATE_CHECK` disables the startup check for newer releases (the ecosystem-standard
|
|
81
|
+
`NO_UPDATE_NOTIFIER` works too). The check is also off automatically under `CI`, in pipes,
|
|
82
|
+
and in dev builds; it never blocks or fails a command.
|
|
83
|
+
|
|
84
|
+
## Publishing from CI
|
|
85
|
+
|
|
86
|
+
`ymmv login` needs a browser, so mint the token on your machine and hand it to CI:
|
|
87
|
+
|
|
88
|
+
1. Run `ymmv login` locally.
|
|
89
|
+
2. Copy the `token` value from the token file:
|
|
90
|
+
`~/.config/ymmv/token.json` (Linux), `~/Library/Preferences/ymmv/token.json` (macOS),
|
|
91
|
+
`%APPDATA%\ymmv\Config\token.json` (Windows).
|
|
92
|
+
3. Set it as a CI secret named `YMMV_TOKEN`, and set `YMMV_HANDLE` to your GitHub username.
|
|
93
|
+
4. Run `npx ymmv-cli@latest -y` in the job.
|
|
94
|
+
|
|
95
|
+
Two things to know:
|
|
96
|
+
|
|
97
|
+
- `ymmv -y` publishes the merge of your existing profile with what it detects on the machine it
|
|
98
|
+
runs on. Values you already published always win, but curated keys you have never set get the
|
|
99
|
+
CI runner's detected values (its OS, shell, and so on). For targeted updates from CI, prefer
|
|
100
|
+
`ymmv set <key> <value>`.
|
|
101
|
+
- A rejected or revoked `YMMV_TOKEN` fails with an error naming the variable; nothing falls back
|
|
102
|
+
to an interactive login, and the stored login file on the runner (if any) is left untouched.
|
|
103
|
+
`ymmv delete` acts on the account the token is bound to, regardless of `YMMV_HANDLE`.
|
|
55
104
|
|
|
56
105
|
## License
|
|
57
106
|
|
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,18 @@ 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
|
+
"update"
|
|
354
|
+
];
|
|
338
355
|
var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
|
|
339
356
|
var RESERVED_SET = new Set(RESERVED);
|
|
340
357
|
function isReserved(handle) {
|
|
@@ -382,7 +399,9 @@ function sanitizeValue(value) {
|
|
|
382
399
|
}
|
|
383
400
|
function useColor(env, isTTY) {
|
|
384
401
|
if (env.NO_COLOR !== void 0) return false;
|
|
385
|
-
if (env.FORCE_COLOR !== void 0)
|
|
402
|
+
if (env.FORCE_COLOR !== void 0) {
|
|
403
|
+
return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
|
|
404
|
+
}
|
|
386
405
|
if (env.TERM === "dumb") return false;
|
|
387
406
|
return isTTY;
|
|
388
407
|
}
|
|
@@ -399,10 +418,10 @@ function isHttpUrl(value) {
|
|
|
399
418
|
return HTTP_URL_RE.test(value.trim());
|
|
400
419
|
}
|
|
401
420
|
var NO_OSC8_TERMS = /* @__PURE__ */ new Set(["linux", "dumb"]);
|
|
402
|
-
function link(url, color, term = process.env.TERM) {
|
|
421
|
+
function link(url, color, term = process.env.TERM, label) {
|
|
403
422
|
const clean = sanitizeValue(url).trim();
|
|
404
423
|
if (!color) return clean;
|
|
405
|
-
const text = `${CODES.amber}${displayUrl(clean)}${CODES.reset}`;
|
|
424
|
+
const text = `${CODES.amber}${label ? sanitizeValue(label) : displayUrl(clean)}${CODES.reset}`;
|
|
406
425
|
if (term !== void 0 && NO_OSC8_TERMS.has(term)) return text;
|
|
407
426
|
return `${OSC}8;;${clean}${ST}${text}${OSC}8;;${ST}`;
|
|
408
427
|
}
|
|
@@ -442,13 +461,15 @@ function renderProfile(profile, opts) {
|
|
|
442
461
|
const val = (v) => isHttpUrl(v) ? link(v, opts.color) : v;
|
|
443
462
|
const lines = [
|
|
444
463
|
"",
|
|
445
|
-
` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}
|
|
446
|
-
""
|
|
464
|
+
` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`
|
|
447
465
|
];
|
|
448
|
-
|
|
449
|
-
lines.push(
|
|
450
|
-
|
|
451
|
-
|
|
466
|
+
if (rows.length) {
|
|
467
|
+
lines.push("");
|
|
468
|
+
for (const r of rows) {
|
|
469
|
+
lines.push(
|
|
470
|
+
r.value === null ? ` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}` : ` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
452
473
|
}
|
|
453
474
|
if (extras.length) {
|
|
454
475
|
lines.push("");
|
|
@@ -519,7 +540,7 @@ function nudge(color) {
|
|
|
519
540
|
function notFound(handle, color, base) {
|
|
520
541
|
return `
|
|
521
542
|
no ymmv profile for "${sanitizeValue(handle)}" yet.
|
|
522
|
-
publish one at ${link(base, color)} with: npx ymmv-cli`;
|
|
543
|
+
publish one at ${link(base, color)} with: npx ymmv-cli@latest`;
|
|
523
544
|
}
|
|
524
545
|
|
|
525
546
|
// src/http.ts
|
|
@@ -553,17 +574,30 @@ function wireText(text) {
|
|
|
553
574
|
return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
|
|
554
575
|
}
|
|
555
576
|
var INVISIBLE_RE = new RegExp("\\p{Default_Ignorable_Code_Point}", "gu");
|
|
556
|
-
async function
|
|
577
|
+
async function wireBody(res) {
|
|
578
|
+
try {
|
|
579
|
+
return await res.text();
|
|
580
|
+
} catch (err) {
|
|
581
|
+
if (isTimeoutError(err)) throw err;
|
|
582
|
+
return "";
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function wireErrorBody(raw) {
|
|
557
586
|
try {
|
|
558
|
-
const body =
|
|
587
|
+
const body = JSON.parse(raw);
|
|
588
|
+
const out = {};
|
|
589
|
+
if (typeof body?.error === "string") out.slug = body.error;
|
|
559
590
|
if (typeof body?.message === "string") {
|
|
560
591
|
const clean = wireText(body.message).trim();
|
|
561
|
-
if (clean.replace(INVISIBLE_RE, "").trim())
|
|
592
|
+
if (clean.replace(INVISIBLE_RE, "").trim()) out.message = clean;
|
|
562
593
|
}
|
|
563
|
-
|
|
564
|
-
|
|
594
|
+
return out;
|
|
595
|
+
} catch {
|
|
596
|
+
return {};
|
|
565
597
|
}
|
|
566
|
-
|
|
598
|
+
}
|
|
599
|
+
async function serverMessage(res) {
|
|
600
|
+
return wireErrorBody(await wireBody(res)).message;
|
|
567
601
|
}
|
|
568
602
|
function withRetryHint(msg, res) {
|
|
569
603
|
const retry = res.headers.get("retry-after");
|
|
@@ -574,6 +608,12 @@ function displayError(err) {
|
|
|
574
608
|
const text = err instanceof Error ? err.message : String(err);
|
|
575
609
|
return text.split(/\r?\n/).map((line) => sanitizeValue(line)).join("\n");
|
|
576
610
|
}
|
|
611
|
+
var NetworkError = class extends Error {
|
|
612
|
+
constructor(message2, options) {
|
|
613
|
+
super(message2, options);
|
|
614
|
+
this.name = "NetworkError";
|
|
615
|
+
}
|
|
616
|
+
};
|
|
577
617
|
async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
578
618
|
try {
|
|
579
619
|
return await fetchFn(url, {
|
|
@@ -581,17 +621,58 @@ async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
|
581
621
|
signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
582
622
|
});
|
|
583
623
|
} catch (err) {
|
|
584
|
-
throw new
|
|
624
|
+
throw new NetworkError(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
|
|
585
625
|
cause: err
|
|
586
626
|
});
|
|
587
627
|
}
|
|
588
628
|
}
|
|
589
629
|
|
|
590
|
-
// src/index.ts
|
|
591
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
592
|
-
|
|
593
630
|
// src/config.ts
|
|
594
|
-
|
|
631
|
+
function normalizeBase(raw) {
|
|
632
|
+
return raw.replace(/\/+$/, "");
|
|
633
|
+
}
|
|
634
|
+
var BASE = normalizeBase(process.env.YMMV_API || "https://ymmv.fyi");
|
|
635
|
+
function baseProblem(raw = process.env.YMMV_API) {
|
|
636
|
+
if (raw === void 0 || raw === "") return null;
|
|
637
|
+
const shown = `YMMV_API is set to "${sanitizeValue(raw)}"`;
|
|
638
|
+
if (/\s/.test(raw)) return `${shown} which contains whitespace. Remove it.`;
|
|
639
|
+
const base = normalizeBase(raw);
|
|
640
|
+
let url;
|
|
641
|
+
try {
|
|
642
|
+
url = new URL(base);
|
|
643
|
+
} catch {
|
|
644
|
+
return `${shown} which is not a full URL. Use a bare origin like https://ymmv.fyi (the http:// or https:// scheme is required).`;
|
|
645
|
+
}
|
|
646
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
647
|
+
return `${shown} which is not an http or https URL. Use a bare origin like https://ymmv.fyi.`;
|
|
648
|
+
}
|
|
649
|
+
if (url.username || url.password) {
|
|
650
|
+
return `${shown} which contains credentials. Use a bare origin like https://ymmv.fyi.`;
|
|
651
|
+
}
|
|
652
|
+
if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
|
|
653
|
+
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.`;
|
|
654
|
+
}
|
|
655
|
+
if (base !== url.origin) {
|
|
656
|
+
return `${shown} which is not in canonical form. Use exactly the scheme and host, like https://ymmv.fyi.`;
|
|
657
|
+
}
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
function credentialEnvProblem(rawToken = process.env.YMMV_TOKEN, rawHandle = process.env.YMMV_HANDLE) {
|
|
661
|
+
if (rawToken === void 0 || rawToken === "") return null;
|
|
662
|
+
if (!/^[\x21-\x7E]+$/.test(rawToken)) {
|
|
663
|
+
return "YMMV_TOKEN contains whitespace, control, or non-ASCII characters. Set it to the exact token value.";
|
|
664
|
+
}
|
|
665
|
+
if (rawHandle !== void 0 && rawHandle !== "") {
|
|
666
|
+
const shown = `YMMV_HANDLE is set to "${sanitizeValue(rawHandle)}"`;
|
|
667
|
+
if (!isValidHandle(rawHandle)) {
|
|
668
|
+
return `${shown} which is not a valid GitHub username. Set it to the handle bound to YMMV_TOKEN.`;
|
|
669
|
+
}
|
|
670
|
+
if (isReserved(rawHandle)) {
|
|
671
|
+
return `${shown} which is a reserved word, so no handle can be bound to it.`;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
595
676
|
|
|
596
677
|
// src/auth-http.ts
|
|
597
678
|
async function bodyJson(res) {
|
|
@@ -631,7 +712,16 @@ async function mintYmmvToken(accessToken) {
|
|
|
631
712
|
);
|
|
632
713
|
}
|
|
633
714
|
const body = await bodyJson(res) ?? {};
|
|
634
|
-
|
|
715
|
+
const slug = typeof body.error === "string" ? body.error : "";
|
|
716
|
+
if (res.status === 401 && slug === "github_auth_failed") {
|
|
717
|
+
throw new Error("GitHub rejected the authorization. Run `ymmv login` to try again.");
|
|
718
|
+
}
|
|
719
|
+
if (res.status === 500 && slug === "internal_error") {
|
|
720
|
+
throw new Error(
|
|
721
|
+
"The server hit an error minting your login. Run `ymmv login` again shortly."
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
throw new Error(`login failed: ${res.status} ${wireText(slug)}`.trim());
|
|
635
725
|
}
|
|
636
726
|
const data = await bodyJson(res);
|
|
637
727
|
if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
|
|
@@ -690,38 +780,49 @@ async function saveToken(data) {
|
|
|
690
780
|
throw e;
|
|
691
781
|
}
|
|
692
782
|
}
|
|
693
|
-
async function
|
|
694
|
-
let raw;
|
|
783
|
+
async function readTokenFile() {
|
|
695
784
|
try {
|
|
696
|
-
|
|
785
|
+
const parsed = JSON.parse(await readFile(tokenFilePath(), "utf8"));
|
|
786
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
697
787
|
} catch {
|
|
698
788
|
return null;
|
|
699
789
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
790
|
+
}
|
|
791
|
+
async function loadToken() {
|
|
792
|
+
const parsed = await readTokenFile();
|
|
793
|
+
if (!parsed || parsed.base !== BASE || typeof parsed.token !== "string" || parsed.token === "" || parsed.handle !== null && typeof parsed.handle !== "string") {
|
|
704
794
|
return null;
|
|
705
795
|
}
|
|
706
|
-
if (parsed.base !== BASE || typeof parsed.token !== "string") return null;
|
|
707
796
|
return parsed;
|
|
708
797
|
}
|
|
798
|
+
async function peekCredential() {
|
|
799
|
+
const parsed = await readTokenFile();
|
|
800
|
+
return parsed && typeof parsed.base === "string" && typeof parsed.token === "string" && parsed.token !== "" ? { base: parsed.base, token: parsed.token } : null;
|
|
801
|
+
}
|
|
802
|
+
async function loadCredential() {
|
|
803
|
+
const envToken = process.env.YMMV_TOKEN || "";
|
|
804
|
+
if (envToken !== "") {
|
|
805
|
+
return { base: BASE, token: envToken, handle: process.env.YMMV_HANDLE || null, source: "env" };
|
|
806
|
+
}
|
|
807
|
+
const stored = await loadToken();
|
|
808
|
+
return stored ? { ...stored, source: "file" } : null;
|
|
809
|
+
}
|
|
709
810
|
async function deleteToken() {
|
|
710
811
|
await rm(tokenFilePath(), { force: true });
|
|
711
812
|
}
|
|
712
813
|
async function peekBase() {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
return typeof parsed.base === "string" ? parsed.base : null;
|
|
716
|
-
} catch {
|
|
717
|
-
return null;
|
|
718
|
-
}
|
|
814
|
+
const parsed = await readTokenFile();
|
|
815
|
+
return typeof parsed?.base === "string" ? parsed.base : null;
|
|
719
816
|
}
|
|
720
817
|
|
|
721
818
|
// src/device-flow.ts
|
|
722
819
|
var DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
723
820
|
var TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
724
821
|
var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
822
|
+
var DEFAULT_POLL_INTERVAL_S = 5;
|
|
823
|
+
function usableInterval(v) {
|
|
824
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 900;
|
|
825
|
+
}
|
|
725
826
|
async function requestDeviceCode(deps = {}) {
|
|
726
827
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
727
828
|
const res = await safeFetch(
|
|
@@ -741,7 +842,10 @@ async function requestDeviceCode(deps = {}) {
|
|
|
741
842
|
if (isTimeoutError(err)) throw err;
|
|
742
843
|
return null;
|
|
743
844
|
});
|
|
744
|
-
if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" ||
|
|
845
|
+
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
|
|
846
|
+
// "expired"), Infinity/1e300 make it unreachable (a middlebox feeding parseable
|
|
847
|
+
// authorization_pending bodies would hold the login forever - the deadline is the only exit).
|
|
848
|
+
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
849
|
throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
|
|
746
850
|
}
|
|
747
851
|
return data;
|
|
@@ -750,7 +854,7 @@ async function pollForToken(dc, deps = {}) {
|
|
|
750
854
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
751
855
|
const sleep = deps.sleep ?? realSleep;
|
|
752
856
|
const now = deps.now ?? Date.now;
|
|
753
|
-
let interval = dc.interval
|
|
857
|
+
let interval = usableInterval(dc.interval) ? dc.interval : DEFAULT_POLL_INTERVAL_S;
|
|
754
858
|
const deadline = now() + dc.expires_in * 1e3;
|
|
755
859
|
let transientFailures = 0;
|
|
756
860
|
let lastCause = "";
|
|
@@ -802,7 +906,7 @@ async function pollForToken(dc, deps = {}) {
|
|
|
802
906
|
case "authorization_pending":
|
|
803
907
|
break;
|
|
804
908
|
case "slow_down":
|
|
805
|
-
interval = Math.max(tok.interval
|
|
909
|
+
interval = usableInterval(tok.interval) ? Math.max(tok.interval, interval + DEFAULT_POLL_INTERVAL_S) : interval + DEFAULT_POLL_INTERVAL_S;
|
|
806
910
|
break;
|
|
807
911
|
case "access_denied":
|
|
808
912
|
throw new Error("Authorization denied. Run `ymmv login` to try again.");
|
|
@@ -820,9 +924,24 @@ async function login(deps = {}) {
|
|
|
820
924
|
"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
925
|
);
|
|
822
926
|
}
|
|
823
|
-
|
|
927
|
+
if (process.env.YMMV_TOKEN) {
|
|
928
|
+
console.error(
|
|
929
|
+
message(
|
|
930
|
+
"YMMV_TOKEN is set and takes precedence over stored logins. This login will be saved but not used until you unset it."
|
|
931
|
+
)
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
const prior = await peekCredential();
|
|
824
935
|
const color = colorEnabled();
|
|
825
936
|
const c = palette(color);
|
|
937
|
+
if (prior && prior.base !== BASE) {
|
|
938
|
+
console.error(
|
|
939
|
+
message(
|
|
940
|
+
`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\`.`
|
|
941
|
+
)
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
const dc = await requestDeviceCode(deps);
|
|
826
945
|
const verifyUri = /^https:\/\/github\.com\//.test(dc.verification_uri) ? link(dc.verification_uri, color) : sanitizeValue(dc.verification_uri);
|
|
827
946
|
console.log(
|
|
828
947
|
message(
|
|
@@ -832,6 +951,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
832
951
|
);
|
|
833
952
|
const accessToken = await pollForToken(dc, deps);
|
|
834
953
|
const { token, handle } = await mintYmmvToken(accessToken);
|
|
954
|
+
const replaced = await peekCredential();
|
|
835
955
|
try {
|
|
836
956
|
await saveToken({ token, handle });
|
|
837
957
|
} catch (e) {
|
|
@@ -839,6 +959,13 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
839
959
|
});
|
|
840
960
|
throw e;
|
|
841
961
|
}
|
|
962
|
+
if (replaced && replaced.base === BASE && replaced.token !== token) {
|
|
963
|
+
try {
|
|
964
|
+
await revokeYmmvToken(replaced.token);
|
|
965
|
+
} catch {
|
|
966
|
+
console.error(message(`${c.faint}(couldn't revoke the previous session's token)${c.reset}`));
|
|
967
|
+
}
|
|
968
|
+
}
|
|
842
969
|
console.log(
|
|
843
970
|
message(
|
|
844
971
|
handle ? `Logged in as ${handle}.` : "Logged in. No handle bound (your GitHub username is a reserved word)."
|
|
@@ -847,14 +974,21 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
847
974
|
}
|
|
848
975
|
|
|
849
976
|
// src/api.ts
|
|
977
|
+
var PublishRefusal = class extends Error {
|
|
978
|
+
constructor(msg) {
|
|
979
|
+
super(msg);
|
|
980
|
+
this.name = "PublishRefusal";
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
var ENV_TOKEN_REJECTED = "The server rejected the token in YMMV_TOKEN (revoked or expired).";
|
|
850
984
|
async function rateLimitMessage(res) {
|
|
851
985
|
return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
|
|
852
986
|
}
|
|
853
987
|
async function ensureLogin() {
|
|
854
|
-
const existing = await
|
|
988
|
+
const existing = await loadCredential();
|
|
855
989
|
if (existing) return existing;
|
|
856
990
|
await login();
|
|
857
|
-
const fresh = await
|
|
991
|
+
const fresh = await loadCredential();
|
|
858
992
|
if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
|
|
859
993
|
return fresh;
|
|
860
994
|
}
|
|
@@ -874,35 +1008,60 @@ async function publishProfile(profile) {
|
|
|
874
1008
|
);
|
|
875
1009
|
let cred = await ensureLogin();
|
|
876
1010
|
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
877
|
-
throw new
|
|
1011
|
+
throw new PublishRefusal(
|
|
878
1012
|
"The stored login changed while this command was running. Re-run it under the current account."
|
|
879
1013
|
);
|
|
880
1014
|
}
|
|
881
1015
|
let res = await send(cred);
|
|
882
1016
|
if (res.status === 401 || res.status === 409) {
|
|
1017
|
+
if (cred.source === "env") {
|
|
1018
|
+
throw new PublishRefusal(
|
|
1019
|
+
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`."
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
883
1022
|
const was401 = res.status === 401;
|
|
1023
|
+
console.log(
|
|
1024
|
+
message(
|
|
1025
|
+
was401 ? "Session expired. Logging in again to retry the publish." : "The server no longer recognizes your handle. Logging in again to retry the publish."
|
|
1026
|
+
)
|
|
1027
|
+
);
|
|
884
1028
|
if (was401) await deleteToken();
|
|
885
1029
|
await login();
|
|
886
1030
|
cred = await ensureLogin();
|
|
887
1031
|
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
888
1032
|
const bound = sanitizeValue(cred.handle ?? "");
|
|
889
|
-
throw new
|
|
1033
|
+
throw new PublishRefusal(
|
|
890
1034
|
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
1035
|
);
|
|
892
1036
|
}
|
|
893
1037
|
res = await send(cred);
|
|
894
|
-
if (res.status === 401) throw new
|
|
1038
|
+
if (res.status === 401) throw new PublishRefusal("Authentication failed. Run `ymmv login`.");
|
|
895
1039
|
if (res.status === 409) {
|
|
1040
|
+
const { slug, message: srvMsg } = wireErrorBody(await wireBody(res));
|
|
1041
|
+
if (slug === "handle_not_bound") {
|
|
1042
|
+
throw new PublishRefusal(
|
|
1043
|
+
"The server still refuses this handle after a fresh login. Wait a moment and re-run the command."
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
896
1046
|
throw new Error(
|
|
897
|
-
|
|
1047
|
+
srvMsg ?? "that handle is taken by another account (your GitHub handle may have been reused)."
|
|
898
1048
|
);
|
|
899
1049
|
}
|
|
900
1050
|
}
|
|
901
1051
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
902
1052
|
if (!res.ok) {
|
|
903
|
-
|
|
1053
|
+
const raw = await wireBody(res);
|
|
1054
|
+
const parsed = wireErrorBody(raw);
|
|
1055
|
+
if (res.status === 400 && parsed.slug === "unsupported_schema_version") {
|
|
1056
|
+
throw new PublishRefusal(parsed.message ?? `publish failed: ${res.status} ${wireText(raw)}`);
|
|
1057
|
+
}
|
|
1058
|
+
throw new Error(parsed.message ?? `publish failed: ${res.status} ${wireText(raw)}`);
|
|
1059
|
+
}
|
|
1060
|
+
let data = {};
|
|
1061
|
+
try {
|
|
1062
|
+
data = await res.json();
|
|
1063
|
+
} catch {
|
|
904
1064
|
}
|
|
905
|
-
const data = await res.json();
|
|
906
1065
|
const shown = typeof data.handle === "string" ? sanitizeValue(data.handle) : profile.handle;
|
|
907
1066
|
return { handle: shown, url: `${BASE}/${shown}` };
|
|
908
1067
|
}
|
|
@@ -914,8 +1073,7 @@ async function fetchProfileJson(handle) {
|
|
|
914
1073
|
}
|
|
915
1074
|
return parseProfile(await res.json());
|
|
916
1075
|
}
|
|
917
|
-
async function deleteProfile() {
|
|
918
|
-
const cred = await ensureLogin();
|
|
1076
|
+
async function deleteProfile(cred) {
|
|
919
1077
|
const res = await safeFetch(
|
|
920
1078
|
`${BASE}/api/v1/profile`,
|
|
921
1079
|
{
|
|
@@ -926,11 +1084,14 @@ async function deleteProfile() {
|
|
|
926
1084
|
BASE
|
|
927
1085
|
);
|
|
928
1086
|
if (res.status === 401) {
|
|
929
|
-
throw new Error(
|
|
1087
|
+
throw new Error(
|
|
1088
|
+
cred.source === "env" ? `${ENV_TOKEN_REJECTED} Update YMMV_TOKEN and run \`ymmv delete\` again.` : "Session expired. Run `ymmv login`, then `ymmv delete` again."
|
|
1089
|
+
);
|
|
930
1090
|
}
|
|
931
1091
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
932
1092
|
if (!res.ok) {
|
|
933
|
-
|
|
1093
|
+
const raw = await wireBody(res);
|
|
1094
|
+
throw new Error(wireErrorBody(raw).message ?? `delete failed: ${res.status} ${wireText(raw)}`);
|
|
934
1095
|
}
|
|
935
1096
|
}
|
|
936
1097
|
|
|
@@ -1299,7 +1460,7 @@ function requireHandle(cred) {
|
|
|
1299
1460
|
if (cred.handle) return cred.handle;
|
|
1300
1461
|
console.error(
|
|
1301
1462
|
message(
|
|
1302
|
-
"Your GitHub username is a reserved word, so no handle is bound. Rename on GitHub, then run `ymmv login` again."
|
|
1463
|
+
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
1464
|
)
|
|
1304
1465
|
);
|
|
1305
1466
|
process.exitCode = 1;
|
|
@@ -1334,9 +1495,21 @@ async function promptEntries(defaults, prompter) {
|
|
|
1334
1495
|
console.log(message(`${c.faint}Enter to keep, "-" to clear${c.reset}`));
|
|
1335
1496
|
const chosen = /* @__PURE__ */ new Map();
|
|
1336
1497
|
for (const key of CURATED_KEYS) {
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1498
|
+
for (; ; ) {
|
|
1499
|
+
const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
|
|
1500
|
+
const value = answer === "-" ? "" : answer;
|
|
1501
|
+
if (value.length > MAX_VALUE) {
|
|
1502
|
+
const isDefault = value === (defaults.get(key) ?? "").trim();
|
|
1503
|
+
console.log(
|
|
1504
|
+
message(
|
|
1505
|
+
`${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}`
|
|
1506
|
+
)
|
|
1507
|
+
);
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
if (value) chosen.set(key, value);
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1340
1513
|
}
|
|
1341
1514
|
return chosen;
|
|
1342
1515
|
}
|
|
@@ -1391,6 +1564,16 @@ async function publish(io) {
|
|
|
1391
1564
|
let values = defaults;
|
|
1392
1565
|
const assemble = () => [...entriesFromMap(values), ...carried];
|
|
1393
1566
|
if (!io.interactive || !io.prompter || io.yes) {
|
|
1567
|
+
const over = [...values].find(([, v]) => v.length > MAX_VALUE);
|
|
1568
|
+
if (over) {
|
|
1569
|
+
console.error(
|
|
1570
|
+
message(
|
|
1571
|
+
`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>.`
|
|
1572
|
+
)
|
|
1573
|
+
);
|
|
1574
|
+
process.exitCode = 1;
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1394
1577
|
const entries = assemble();
|
|
1395
1578
|
showCard(entries);
|
|
1396
1579
|
printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
|
|
@@ -1408,8 +1591,20 @@ async function publish(io) {
|
|
|
1408
1591
|
"Y/n/e=edit"
|
|
1409
1592
|
);
|
|
1410
1593
|
if (ans === "y") {
|
|
1411
|
-
|
|
1412
|
-
|
|
1594
|
+
try {
|
|
1595
|
+
printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
|
|
1596
|
+
return;
|
|
1597
|
+
} catch (e) {
|
|
1598
|
+
if (e instanceof PromptAborted || e instanceof PublishRefusal) throw e;
|
|
1599
|
+
const ambiguous = e instanceof NetworkError || isTimeoutError(e);
|
|
1600
|
+
console.error(
|
|
1601
|
+
message(
|
|
1602
|
+
`${displayError(e)}
|
|
1603
|
+
${ambiguous ? "The publish may not have completed. Your answers are kept." : "Nothing was published. Your answers are kept."}`
|
|
1604
|
+
)
|
|
1605
|
+
);
|
|
1606
|
+
continue;
|
|
1607
|
+
}
|
|
1413
1608
|
}
|
|
1414
1609
|
if (ans === "n") {
|
|
1415
1610
|
console.log(message("Aborted. Nothing published."));
|
|
@@ -1436,7 +1631,11 @@ async function view(handle) {
|
|
|
1436
1631
|
}
|
|
1437
1632
|
const cred = await loadToken();
|
|
1438
1633
|
if (cred?.handle) {
|
|
1439
|
-
|
|
1634
|
+
let mineFailed = false;
|
|
1635
|
+
const mine = await fetchProfileJson(cred.handle).catch(() => {
|
|
1636
|
+
mineFailed = true;
|
|
1637
|
+
return null;
|
|
1638
|
+
});
|
|
1440
1639
|
if (mine && mine.handle.toLowerCase() !== theirs.handle.toLowerCase()) {
|
|
1441
1640
|
console.log(
|
|
1442
1641
|
renderDiff(diff(mine, theirs), { color: c, theirsLabel: theirs.handle, mineLabel: "you" })
|
|
@@ -1445,7 +1644,12 @@ async function view(handle) {
|
|
|
1445
1644
|
}
|
|
1446
1645
|
if (!mine) {
|
|
1447
1646
|
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
1448
|
-
|
|
1647
|
+
if (mineFailed) {
|
|
1648
|
+
const codes = palette(c);
|
|
1649
|
+
console.error(message(`${codes.faint}(couldn't load your profile to diff)${codes.reset}`));
|
|
1650
|
+
} else {
|
|
1651
|
+
console.log(nudge(c));
|
|
1652
|
+
}
|
|
1449
1653
|
return;
|
|
1450
1654
|
}
|
|
1451
1655
|
}
|
|
@@ -1458,6 +1662,15 @@ async function runSet(target) {
|
|
|
1458
1662
|
const existing = await fetchProfileJson(handle);
|
|
1459
1663
|
assertHandleUnchanged(existing, handle);
|
|
1460
1664
|
const { entries, extras } = applySet(existing, target);
|
|
1665
|
+
if (target.kind === "extra" && extras.length > MAX_EXTRAS) {
|
|
1666
|
+
console.error(
|
|
1667
|
+
message(
|
|
1668
|
+
`Your profile already has ${MAX_EXTRAS} extras; that's the cap. Remove one first: ymmv unset --extra "Label".`
|
|
1669
|
+
)
|
|
1670
|
+
);
|
|
1671
|
+
process.exitCode = 1;
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1461
1674
|
const res = await publishProfile(newProfile(handle, entries, extras));
|
|
1462
1675
|
const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
|
|
1463
1676
|
console.log(message(`${line}${pagePointer(res.handle)}`));
|
|
@@ -1487,7 +1700,7 @@ async function runUnset(target) {
|
|
|
1487
1700
|
}
|
|
1488
1701
|
async function runDelete(io) {
|
|
1489
1702
|
const cred = await ensureLogin();
|
|
1490
|
-
const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
|
|
1703
|
+
const target = cred.source === "env" ? "the profile bound to YMMV_TOKEN" : cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
|
|
1491
1704
|
if (!io.yes) {
|
|
1492
1705
|
if (!io.interactive || !io.prompter) {
|
|
1493
1706
|
console.error(
|
|
@@ -1515,8 +1728,8 @@ ${message("Cancelled. Nothing deleted.")}`);
|
|
|
1515
1728
|
return;
|
|
1516
1729
|
}
|
|
1517
1730
|
}
|
|
1518
|
-
await deleteProfile();
|
|
1519
|
-
await deleteToken();
|
|
1731
|
+
await deleteProfile(cred);
|
|
1732
|
+
if (cred.source === "file") await deleteToken();
|
|
1520
1733
|
console.log(message(`Deleted ${target}. Run \`ymmv\` to publish again.`));
|
|
1521
1734
|
}
|
|
1522
1735
|
|
|
@@ -1526,15 +1739,33 @@ var UNSET_EXTRA = 'ymmv unset --extra "Label"';
|
|
|
1526
1739
|
var SET_USAGE = `usage: ymmv set <key> <value> | ${SET_EXTRA}`;
|
|
1527
1740
|
var EXTRA_USAGE = `usage: ${SET_EXTRA}`;
|
|
1528
1741
|
var UNSET_USAGE = `usage: ymmv unset <key> | ${UNSET_EXTRA}`;
|
|
1742
|
+
var VIEW_USAGE = "usage: ymmv view <handle>";
|
|
1529
1743
|
function invalidKeyError(head, hint) {
|
|
1530
1744
|
return {
|
|
1531
1745
|
kind: "error",
|
|
1532
|
-
message: `"${head}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
|
|
1746
|
+
message: `"${sanitizeValue(head)}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
|
|
1533
1747
|
For anything else, use: ${hint}.`
|
|
1534
1748
|
};
|
|
1535
1749
|
}
|
|
1536
|
-
function
|
|
1537
|
-
return
|
|
1750
|
+
function noArgs(verb, rest) {
|
|
1751
|
+
return rest.length === 0 ? { kind: verb } : { kind: "error", message: `usage: ymmv ${verb}` };
|
|
1752
|
+
}
|
|
1753
|
+
function yesOnly(usage, rest, make) {
|
|
1754
|
+
if (rest.length === 0) return make(false);
|
|
1755
|
+
if (rest.length === 1 && (rest[0] === "-y" || rest[0] === "--yes")) return make(true);
|
|
1756
|
+
return { kind: "error", message: usage };
|
|
1757
|
+
}
|
|
1758
|
+
function labelCapError(label) {
|
|
1759
|
+
return {
|
|
1760
|
+
kind: "error",
|
|
1761
|
+
message: `That label is ${label.length} characters; the cap is ${MAX_LABEL}.`
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
function valueCapError(value) {
|
|
1765
|
+
return {
|
|
1766
|
+
kind: "error",
|
|
1767
|
+
message: `That value is ${value.length} characters; the cap is ${MAX_VALUE}.`
|
|
1768
|
+
};
|
|
1538
1769
|
}
|
|
1539
1770
|
function parseSet(rest) {
|
|
1540
1771
|
const head = rest[0];
|
|
@@ -1546,6 +1777,8 @@ function parseSet(rest) {
|
|
|
1546
1777
|
const value2 = spec.slice(eq + 1).trim();
|
|
1547
1778
|
if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
|
|
1548
1779
|
if (value2 === "-") return { kind: "unset", target: { kind: "extra", label } };
|
|
1780
|
+
if (label.length > MAX_LABEL) return labelCapError(label);
|
|
1781
|
+
if (value2.length > MAX_VALUE) return valueCapError(value2);
|
|
1549
1782
|
return { kind: "set", target: { kind: "extra", label, value: value2 } };
|
|
1550
1783
|
}
|
|
1551
1784
|
if (!head) return { kind: "error", message: SET_USAGE };
|
|
@@ -1553,6 +1786,7 @@ function parseSet(rest) {
|
|
|
1553
1786
|
const value = rest.slice(1).join(" ").trim();
|
|
1554
1787
|
if (!value) return { kind: "error", message: `usage: ymmv set ${head} <value>` };
|
|
1555
1788
|
if (value === "-") return { kind: "unset", target: { kind: "curated", key: head } };
|
|
1789
|
+
if (value.length > MAX_VALUE) return valueCapError(value);
|
|
1556
1790
|
return { kind: "set", target: { kind: "curated", key: head, value } };
|
|
1557
1791
|
}
|
|
1558
1792
|
function parseUnset(rest) {
|
|
@@ -1575,43 +1809,340 @@ function parseUnset(rest) {
|
|
|
1575
1809
|
}
|
|
1576
1810
|
function resolveArg(argv) {
|
|
1577
1811
|
const first = argv[0];
|
|
1812
|
+
const rest = argv.slice(1);
|
|
1578
1813
|
if (first === "-h" || first === "--help" || first === "help") return { kind: "help" };
|
|
1579
|
-
if (first === "-V" || first === "-v" || first === "--version"
|
|
1814
|
+
if (first === "-V" || first === "-v" || first === "--version" || first === "version") {
|
|
1815
|
+
return rest.length === 0 ? { kind: "version" } : { kind: "error", message: "usage: ymmv version" };
|
|
1816
|
+
}
|
|
1580
1817
|
if (first === void 0) return { kind: "publish", yes: false };
|
|
1581
|
-
if (first === "-y" || first === "--yes")
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1818
|
+
if (first === "-y" || first === "--yes") {
|
|
1819
|
+
if (rest.length > 0) {
|
|
1820
|
+
const example = rest[0] === "delete" || rest[0] === "publish" ? rest[0] : "publish";
|
|
1821
|
+
return {
|
|
1822
|
+
kind: "error",
|
|
1823
|
+
message: `Put ${first} after the command: ymmv ${example} -y. A bare ymmv -y publishes without prompts.`
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
return { kind: "publish", yes: true };
|
|
1827
|
+
}
|
|
1828
|
+
if (first === "login" || first === "logout" || first === "update") return noArgs(first, rest);
|
|
1829
|
+
if (first === "publish") {
|
|
1830
|
+
return yesOnly("usage: ymmv publish [-y]", rest, (yes) => ({ kind: "publish", yes }));
|
|
1831
|
+
}
|
|
1832
|
+
if (first === "delete") {
|
|
1833
|
+
return yesOnly(
|
|
1834
|
+
"usage: ymmv delete [-y] (deletes your own profile; takes no handle)",
|
|
1835
|
+
rest,
|
|
1836
|
+
(yes) => ({ kind: "delete", yes })
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
if (first === "set") return parseSet(rest);
|
|
1840
|
+
if (first === "unset") return parseUnset(rest);
|
|
1587
1841
|
if (first === "view") {
|
|
1588
|
-
const handle =
|
|
1589
|
-
if (!handle) return { kind: "error", message:
|
|
1842
|
+
const handle = rest[0];
|
|
1843
|
+
if (!handle) return { kind: "error", message: VIEW_USAGE };
|
|
1590
1844
|
if (!isValidHandle(handle)) {
|
|
1591
|
-
return { kind: "error", message: `"${handle}" is not a valid GitHub handle.` };
|
|
1845
|
+
return { kind: "error", message: `"${sanitizeValue(handle)}" is not a valid GitHub handle.` };
|
|
1592
1846
|
}
|
|
1593
1847
|
if (isReserved(handle)) return reservedError(handle);
|
|
1848
|
+
if (rest.length > 1) return { kind: "error", message: VIEW_USAGE };
|
|
1594
1849
|
return { kind: "view", handle };
|
|
1595
1850
|
}
|
|
1596
1851
|
if (first.startsWith("-")) {
|
|
1597
|
-
return {
|
|
1852
|
+
return {
|
|
1853
|
+
kind: "error",
|
|
1854
|
+
message: `Unknown option "${sanitizeValue(first)}". Run \`ymmv help\`.`
|
|
1855
|
+
};
|
|
1598
1856
|
}
|
|
1599
1857
|
if (!isValidHandle(first)) {
|
|
1600
1858
|
return {
|
|
1601
1859
|
kind: "error",
|
|
1602
|
-
message: `"${first}" is not a valid GitHub handle. Run \`ymmv help\`.`
|
|
1860
|
+
message: `"${sanitizeValue(first)}" is not a valid GitHub handle. Run \`ymmv help\`.`
|
|
1603
1861
|
};
|
|
1604
1862
|
}
|
|
1605
|
-
if (isReserved(first)) return reservedError(first);
|
|
1863
|
+
if (isReserved(first)) return reservedError(first, true);
|
|
1864
|
+
if (rest.length > 0) {
|
|
1865
|
+
return { kind: "error", message: `Unexpected arguments after "${first}". Run \`ymmv help\`.` };
|
|
1866
|
+
}
|
|
1606
1867
|
return { kind: "view", handle: first };
|
|
1607
1868
|
}
|
|
1608
|
-
function reservedError(handle) {
|
|
1869
|
+
function reservedError(handle, hintVerbs = false) {
|
|
1870
|
+
const verb = handle.toLowerCase();
|
|
1871
|
+
const hint = hintVerbs && CLI_VERBS.includes(verb) ? ` Did you mean: ymmv ${verb}?` : "";
|
|
1609
1872
|
return {
|
|
1610
1873
|
kind: "error",
|
|
1611
|
-
message: `"${handle}" is a reserved name; it can't have a profile
|
|
1874
|
+
message: `"${handle}" is a reserved name; it can't have a profile.${hint}`
|
|
1612
1875
|
};
|
|
1613
1876
|
}
|
|
1614
1877
|
|
|
1878
|
+
// src/update.ts
|
|
1879
|
+
import { spawn as nodeSpawn } from "child_process";
|
|
1880
|
+
import { homedir } from "os";
|
|
1881
|
+
|
|
1882
|
+
// src/update-check.ts
|
|
1883
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1884
|
+
import { readFileSync as readFileSync2, realpathSync } from "fs";
|
|
1885
|
+
import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
1886
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
1887
|
+
import envPaths2 from "env-paths";
|
|
1888
|
+
var REGISTRY_URL = "https://registry.npmjs.org/ymmv-cli/latest";
|
|
1889
|
+
var RELEASES_URL = "https://github.com/ymmv-fyi/ymmv/releases";
|
|
1890
|
+
var FETCH_TIMEOUT_MS = 2e3;
|
|
1891
|
+
var FINISH_GRACE_MS = 500;
|
|
1892
|
+
var SUCCESS_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1893
|
+
var FAILURE_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
1894
|
+
var VERSION_FRESH_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
1895
|
+
function ownVersion() {
|
|
1896
|
+
try {
|
|
1897
|
+
const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
|
|
1898
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
1899
|
+
} catch {
|
|
1900
|
+
return null;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
function parseTriple(v) {
|
|
1904
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v);
|
|
1905
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
1906
|
+
}
|
|
1907
|
+
function isNewer(latest, current) {
|
|
1908
|
+
const l = parseTriple(latest);
|
|
1909
|
+
const c = parseTriple(current);
|
|
1910
|
+
if (!l || !c) return false;
|
|
1911
|
+
for (let i = 0; i < 3; i++) {
|
|
1912
|
+
if (l[i] !== c[i]) return l[i] > c[i];
|
|
1913
|
+
}
|
|
1914
|
+
return false;
|
|
1915
|
+
}
|
|
1916
|
+
function updateCachePath() {
|
|
1917
|
+
return join2(envPaths2("ymmv", { suffix: "" }).config, "update-check.json");
|
|
1918
|
+
}
|
|
1919
|
+
async function readCache(path, now) {
|
|
1920
|
+
try {
|
|
1921
|
+
const parsed = JSON.parse(await readFile2(path, "utf8"));
|
|
1922
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
1923
|
+
const c = parsed;
|
|
1924
|
+
if (typeof c.lastCheckedAt !== "number" || typeof c.ok !== "boolean") return null;
|
|
1925
|
+
if (!Number.isFinite(c.lastCheckedAt) || c.lastCheckedAt > now) return null;
|
|
1926
|
+
if (c.latest !== void 0 && typeof c.latest !== "string") return null;
|
|
1927
|
+
if (c.latestAt !== void 0 && (!Number.isFinite(c.latestAt) || c.latestAt > now)) return null;
|
|
1928
|
+
return { lastCheckedAt: c.lastCheckedAt, ok: c.ok, latest: c.latest, latestAt: c.latestAt };
|
|
1929
|
+
} catch {
|
|
1930
|
+
return null;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
async function writeCache(path, cache) {
|
|
1934
|
+
const tmp = `${path}.${randomUUID2()}.tmp`;
|
|
1935
|
+
try {
|
|
1936
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
1937
|
+
await writeFile2(tmp, JSON.stringify(cache));
|
|
1938
|
+
await rename2(tmp, path);
|
|
1939
|
+
} catch {
|
|
1940
|
+
await rm2(tmp, { force: true }).catch(() => {
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
function binRealpath() {
|
|
1945
|
+
const raw = process.argv[1];
|
|
1946
|
+
if (!raw) return void 0;
|
|
1947
|
+
try {
|
|
1948
|
+
return realpathSync(raw);
|
|
1949
|
+
} catch {
|
|
1950
|
+
return raw;
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
var NPM_PREFIX_ROOTS = /* @__PURE__ */ new Set(["usr", "local", "homebrew", "nodejs", "node", "installation"]);
|
|
1954
|
+
var VERSIONISH_RE = /^v?\d+(\.\d+)*$/;
|
|
1955
|
+
function detectInstallMethod(binPath) {
|
|
1956
|
+
if (!binPath) return "unknown";
|
|
1957
|
+
const segs = binPath.split(/[\\/]+/).filter(Boolean).map((s) => s.toLowerCase());
|
|
1958
|
+
const has = (name) => segs.includes(name);
|
|
1959
|
+
if (has("_npx")) return "ephemeral";
|
|
1960
|
+
if (has("dlx") || segs.some((s) => s.startsWith("dlx-"))) return "ephemeral";
|
|
1961
|
+
if (segs.some((s) => s.startsWith("bunx-"))) return "ephemeral";
|
|
1962
|
+
if (has(".bun") && has("cache")) return "ephemeral";
|
|
1963
|
+
if (has(".bun") && (has("global") || has("bin"))) return "bun-global";
|
|
1964
|
+
if (has(".volta")) return "unknown";
|
|
1965
|
+
if (segs.some((s, i) => s === "pnpm" && segs[i + 1] === "global")) return "pnpm-global";
|
|
1966
|
+
if (has(".pnpm")) return "unknown";
|
|
1967
|
+
const npmGlobal = segs.some(
|
|
1968
|
+
(s, i) => s === "node_modules" && segs[i + 1] === "ymmv-cli" && (segs[i - 1] === "lib" && i >= 2 && (NPM_PREFIX_ROOTS.has(segs[i - 2]) || VERSIONISH_RE.test(segs[i - 2])) || segs[i - 1] === "npm")
|
|
1969
|
+
);
|
|
1970
|
+
if (npmGlobal) return "npm-global";
|
|
1971
|
+
return "unknown";
|
|
1972
|
+
}
|
|
1973
|
+
function npxInvocation(latest) {
|
|
1974
|
+
const triple = latest ? parseTriple(latest) : null;
|
|
1975
|
+
return `npx ymmv-cli@${triple ? triple.join(".") : "latest"}`;
|
|
1976
|
+
}
|
|
1977
|
+
function formatUpdateNotice(current, latest, method, color, term) {
|
|
1978
|
+
const cur = parseTriple(current);
|
|
1979
|
+
const lat = parseTriple(latest);
|
|
1980
|
+
if (!cur || !lat || !isNewer(lat.join("."), cur.join("."))) return null;
|
|
1981
|
+
const latestClean = lat.join(".");
|
|
1982
|
+
const currentClean = cur.join(".");
|
|
1983
|
+
const cmd = method === "ephemeral" ? npxInvocation(latestClean) : "ymmv update";
|
|
1984
|
+
const c = palette(color);
|
|
1985
|
+
const headline = `ymmv-cli ${latestClean} is out`;
|
|
1986
|
+
const linked = color ? link(RELEASES_URL, true, term, headline) : headline;
|
|
1987
|
+
return `${linked} ${c.faint}(you have ${currentClean})${c.reset} \u2192 run ${c.bold}${cmd}${c.reset}`;
|
|
1988
|
+
}
|
|
1989
|
+
function updatesOptedOut(env) {
|
|
1990
|
+
if (env.YMMV_NO_UPDATE_CHECK) return true;
|
|
1991
|
+
if (env.NO_UPDATE_NOTIFIER) return true;
|
|
1992
|
+
if (env.CI && env.CI !== "false") return true;
|
|
1993
|
+
return false;
|
|
1994
|
+
}
|
|
1995
|
+
async function readCachedLatest(deps = {}) {
|
|
1996
|
+
const now = (deps.now ?? Date.now)();
|
|
1997
|
+
const cache = await readCache(deps.cachePath ?? updateCachePath(), now);
|
|
1998
|
+
if (!cache?.latest || cache.latestAt === void 0) return null;
|
|
1999
|
+
if (now - cache.latestAt > VERSION_FRESH_MS) return null;
|
|
2000
|
+
const triple = parseTriple(cache.latest);
|
|
2001
|
+
return triple ? triple.join(".") : null;
|
|
2002
|
+
}
|
|
2003
|
+
var GRACE = /* @__PURE__ */ Symbol("grace");
|
|
2004
|
+
function startUpdateCheck(deps = {}) {
|
|
2005
|
+
const env = deps.env ?? process.env;
|
|
2006
|
+
const nowFn = deps.now ?? Date.now;
|
|
2007
|
+
const stderrIsTTY = deps.stderrIsTTY ?? Boolean(process.stderr.isTTY);
|
|
2008
|
+
const inert = { finish: async () => null, abort: () => {
|
|
2009
|
+
} };
|
|
2010
|
+
if (updatesOptedOut(env)) return inert;
|
|
2011
|
+
if (!stderrIsTTY) return inert;
|
|
2012
|
+
const current = deps.currentVersion !== void 0 ? deps.currentVersion : ownVersion();
|
|
2013
|
+
const currentTriple = current === null ? null : parseTriple(current);
|
|
2014
|
+
if (!currentTriple || currentTriple.join(".") === "0.0.0") return inert;
|
|
2015
|
+
const currentClean = currentTriple.join(".");
|
|
2016
|
+
const cachePath = deps.cachePath ?? updateCachePath();
|
|
2017
|
+
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
2018
|
+
const method = detectInstallMethod(deps.binPath ?? binRealpath());
|
|
2019
|
+
const color = useColor(env, stderrIsTTY);
|
|
2020
|
+
const term = env.TERM;
|
|
2021
|
+
const controller = new AbortController();
|
|
2022
|
+
const notice = (latest) => latest ? formatUpdateNotice(currentClean, latest, method, color, term) : null;
|
|
2023
|
+
const check = (async () => {
|
|
2024
|
+
const cache = await readCache(cachePath, nowFn());
|
|
2025
|
+
if (cache && nowFn() - cache.lastCheckedAt < (cache.ok ? SUCCESS_TTL_MS : FAILURE_TTL_MS)) {
|
|
2026
|
+
return notice(cache.latest);
|
|
2027
|
+
}
|
|
2028
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
2029
|
+
timeout.unref?.();
|
|
2030
|
+
let fetched = null;
|
|
2031
|
+
try {
|
|
2032
|
+
const res = await doFetch(REGISTRY_URL, {
|
|
2033
|
+
signal: controller.signal,
|
|
2034
|
+
headers: { accept: "application/json" }
|
|
2035
|
+
});
|
|
2036
|
+
if (res.ok) {
|
|
2037
|
+
const body = await res.json();
|
|
2038
|
+
if (typeof body.version === "string") fetched = body.version;
|
|
2039
|
+
}
|
|
2040
|
+
} catch {
|
|
2041
|
+
} finally {
|
|
2042
|
+
clearTimeout(timeout);
|
|
2043
|
+
}
|
|
2044
|
+
const triple = fetched === null ? null : parseTriple(fetched);
|
|
2045
|
+
const canonical2 = triple ? triple.join(".") : void 0;
|
|
2046
|
+
const now = nowFn();
|
|
2047
|
+
await writeCache(cachePath, {
|
|
2048
|
+
lastCheckedAt: now,
|
|
2049
|
+
ok: canonical2 !== void 0,
|
|
2050
|
+
latest: canonical2 ?? cache?.latest,
|
|
2051
|
+
latestAt: canonical2 !== void 0 ? now : cache?.latestAt
|
|
2052
|
+
});
|
|
2053
|
+
return notice(canonical2 ?? cache?.latest);
|
|
2054
|
+
})().catch(() => null);
|
|
2055
|
+
return {
|
|
2056
|
+
async finish() {
|
|
2057
|
+
const result = await new Promise((resolve) => {
|
|
2058
|
+
const timer = setTimeout(() => resolve(GRACE), FINISH_GRACE_MS);
|
|
2059
|
+
timer.unref?.();
|
|
2060
|
+
void check.then((v) => {
|
|
2061
|
+
clearTimeout(timer);
|
|
2062
|
+
resolve(v);
|
|
2063
|
+
});
|
|
2064
|
+
});
|
|
2065
|
+
if (result === GRACE) {
|
|
2066
|
+
controller.abort();
|
|
2067
|
+
return null;
|
|
2068
|
+
}
|
|
2069
|
+
return result;
|
|
2070
|
+
},
|
|
2071
|
+
abort() {
|
|
2072
|
+
controller.abort();
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
// src/update.ts
|
|
2078
|
+
var UPGRADE_COMMANDS = {
|
|
2079
|
+
"npm-global": "npm i -g ymmv-cli@latest",
|
|
2080
|
+
"pnpm-global": "pnpm add -g ymmv-cli@latest",
|
|
2081
|
+
"bun-global": "bun add -g ymmv-cli@latest"
|
|
2082
|
+
};
|
|
2083
|
+
var MANUAL_LIST = Object.values(UPGRADE_COMMANDS).map((cmd) => ` ${cmd}`).join("\n");
|
|
2084
|
+
var NOT_FOUND_CODES = /* @__PURE__ */ new Set([127, 9009]);
|
|
2085
|
+
function manualFallback(intro) {
|
|
2086
|
+
return `${intro} Update with the command that matches your setup:
|
|
2087
|
+
${MANUAL_LIST}`;
|
|
2088
|
+
}
|
|
2089
|
+
async function runUpdate(deps = {}) {
|
|
2090
|
+
const method = detectInstallMethod(deps.binPath ?? binRealpath());
|
|
2091
|
+
const c = palette(colorEnabled());
|
|
2092
|
+
if (method === "ephemeral") {
|
|
2093
|
+
const cached = await readCachedLatest({ now: deps.now, cachePath: deps.cachePath });
|
|
2094
|
+
const current = deps.currentVersion !== void 0 ? deps.currentVersion : ownVersion();
|
|
2095
|
+
const pin = cached !== null && current !== null && isNewer(cached, current) ? cached : null;
|
|
2096
|
+
console.log(
|
|
2097
|
+
message(
|
|
2098
|
+
`This run came from a package runner cache (npx-style), so there is no install to update.
|
|
2099
|
+
Next time run: ${c.bold}${npxInvocation(pin)}${c.reset}`
|
|
2100
|
+
)
|
|
2101
|
+
);
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
if (method === "unknown") {
|
|
2105
|
+
console.log(message(manualFallback("Couldn't tell how ymmv-cli was installed.")));
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
const cmd = UPGRADE_COMMANDS[method];
|
|
2109
|
+
const doSpawn = deps.spawn ?? nodeSpawn;
|
|
2110
|
+
const failSpawn = () => {
|
|
2111
|
+
console.error(message(manualFallback(`Couldn't run \`${cmd}\`.`)));
|
|
2112
|
+
process.exitCode = 1;
|
|
2113
|
+
};
|
|
2114
|
+
console.log(message(`${c.faint}running${c.reset} ${c.bold}${cmd}${c.reset}`));
|
|
2115
|
+
await new Promise((resolve) => {
|
|
2116
|
+
let child;
|
|
2117
|
+
try {
|
|
2118
|
+
child = doSpawn(cmd, { stdio: "inherit", shell: true, cwd: homedir() });
|
|
2119
|
+
} catch {
|
|
2120
|
+
failSpawn();
|
|
2121
|
+
resolve();
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
let settled = false;
|
|
2125
|
+
const settle = (fn) => {
|
|
2126
|
+
if (settled) return;
|
|
2127
|
+
settled = true;
|
|
2128
|
+
fn();
|
|
2129
|
+
resolve();
|
|
2130
|
+
};
|
|
2131
|
+
child.on("error", () => {
|
|
2132
|
+
settle(failSpawn);
|
|
2133
|
+
});
|
|
2134
|
+
child.on("exit", (code) => {
|
|
2135
|
+
settle(() => {
|
|
2136
|
+
if (code !== null && NOT_FOUND_CODES.has(code)) {
|
|
2137
|
+
failSpawn();
|
|
2138
|
+
} else if (code) {
|
|
2139
|
+
process.exitCode = code;
|
|
2140
|
+
}
|
|
2141
|
+
});
|
|
2142
|
+
});
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
|
|
1615
2146
|
// src/index.ts
|
|
1616
2147
|
var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
|
|
1617
2148
|
|
|
@@ -1621,12 +2152,13 @@ ${c.faint}Usage:${c.reset}
|
|
|
1621
2152
|
ymmv <handle> view a profile; logged in, see the diff vs yours
|
|
1622
2153
|
ymmv view <handle> explicit view (same as ymmv <handle>)
|
|
1623
2154
|
ymmv set <key> <value> set one curated key
|
|
1624
|
-
ymmv set --extra "L=V" set a free-form extra
|
|
2155
|
+
ymmv set --extra "L=V" set a free-form extra (-e works too)
|
|
1625
2156
|
ymmv unset <key> remove one curated key (ymmv set <key> - works too)
|
|
1626
2157
|
ymmv unset --extra "L" remove a free-form extra
|
|
1627
|
-
ymmv delete
|
|
2158
|
+
ymmv delete [-y] delete your profile (permanent; -y skips the confirm)
|
|
1628
2159
|
ymmv login | logout GitHub device-flow auth
|
|
1629
|
-
ymmv
|
|
2160
|
+
ymmv update update ymmv-cli to the latest release
|
|
2161
|
+
ymmv help | version
|
|
1630
2162
|
|
|
1631
2163
|
${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser, window-manager,
|
|
1632
2164
|
font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
|
|
@@ -1636,7 +2168,7 @@ async function logout() {
|
|
|
1636
2168
|
const otherBase = await peekBase();
|
|
1637
2169
|
console.log(
|
|
1638
2170
|
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."
|
|
2171
|
+
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
2172
|
)
|
|
1641
2173
|
);
|
|
1642
2174
|
return;
|
|
@@ -1644,10 +2176,10 @@ async function logout() {
|
|
|
1644
2176
|
let revoked;
|
|
1645
2177
|
try {
|
|
1646
2178
|
revoked = await revokeYmmvToken(stored.token);
|
|
1647
|
-
} catch {
|
|
2179
|
+
} catch (e) {
|
|
1648
2180
|
console.error(
|
|
1649
2181
|
message(
|
|
1650
|
-
"Couldn't reach the server to revoke. Your token is still active. Run `ymmv logout` again when connected."
|
|
2182
|
+
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
2183
|
)
|
|
1652
2184
|
);
|
|
1653
2185
|
process.exitCode = 1;
|
|
@@ -1657,12 +2189,21 @@ async function logout() {
|
|
|
1657
2189
|
console.log(message(revoked ? "Logged out." : "Logged out (no active session on this server)."));
|
|
1658
2190
|
}
|
|
1659
2191
|
function printVersion() {
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
2192
|
+
const version = ownVersion();
|
|
2193
|
+
console.log(version === null ? "ymmv-cli (version unknown)" : `ymmv-cli ${version}`);
|
|
2194
|
+
}
|
|
2195
|
+
async function printLatestHint() {
|
|
2196
|
+
if (!process.stderr.isTTY) return;
|
|
2197
|
+
if (updatesOptedOut(process.env)) return;
|
|
2198
|
+
const current = ownVersion();
|
|
2199
|
+
const currentTriple = current === null ? null : parseTriple(current);
|
|
2200
|
+
if (!currentTriple || currentTriple.join(".") === "0.0.0") return;
|
|
2201
|
+
const latest = await readCachedLatest();
|
|
2202
|
+
if (latest === null || !isNewer(latest, currentTriple.join("."))) return;
|
|
2203
|
+
const c = palette(useColor(process.env, true));
|
|
2204
|
+
console.error(
|
|
2205
|
+
message(`${c.faint}latest: ${latest}${c.reset} \u2192 run ${c.bold}ymmv update${c.reset}`)
|
|
2206
|
+
);
|
|
1666
2207
|
}
|
|
1667
2208
|
async function interactive(run, yes) {
|
|
1668
2209
|
const isTTY = Boolean(process.stdin.isTTY);
|
|
@@ -1673,8 +2214,30 @@ async function interactive(run, yes) {
|
|
|
1673
2214
|
prompter?.close();
|
|
1674
2215
|
}
|
|
1675
2216
|
}
|
|
2217
|
+
function wantsUpdateCheck(kind) {
|
|
2218
|
+
return kind !== "help" && kind !== "version" && kind !== "error" && kind !== "update";
|
|
2219
|
+
}
|
|
1676
2220
|
async function main(argv) {
|
|
1677
2221
|
const cmd = resolveArg(argv);
|
|
2222
|
+
if (cmd.kind !== "logout" && cmd.kind !== "update") {
|
|
2223
|
+
const problem = baseProblem() ?? credentialEnvProblem();
|
|
2224
|
+
if (problem) {
|
|
2225
|
+
console.error(message(problem));
|
|
2226
|
+
process.exitCode = 1;
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
const update = wantsUpdateCheck(cmd.kind) ? startUpdateCheck() : null;
|
|
2231
|
+
try {
|
|
2232
|
+
await dispatch(cmd);
|
|
2233
|
+
} catch (e) {
|
|
2234
|
+
update?.abort();
|
|
2235
|
+
throw e;
|
|
2236
|
+
}
|
|
2237
|
+
const notice = await update?.finish();
|
|
2238
|
+
if (notice) console.error(message(notice));
|
|
2239
|
+
}
|
|
2240
|
+
async function dispatch(cmd) {
|
|
1678
2241
|
switch (cmd.kind) {
|
|
1679
2242
|
case "publish":
|
|
1680
2243
|
await interactive(publish, cmd.yes);
|
|
@@ -1699,12 +2262,23 @@ async function main(argv) {
|
|
|
1699
2262
|
}
|
|
1700
2263
|
case "logout":
|
|
1701
2264
|
await logout();
|
|
2265
|
+
if (process.env.YMMV_TOKEN) {
|
|
2266
|
+
console.error(
|
|
2267
|
+
message(
|
|
2268
|
+
"Note: YMMV_TOKEN is set and still authenticates API calls. Unset it to stop using that token."
|
|
2269
|
+
)
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
break;
|
|
2273
|
+
case "update":
|
|
2274
|
+
await runUpdate();
|
|
1702
2275
|
break;
|
|
1703
2276
|
case "help":
|
|
1704
2277
|
console.log(help(palette(colorEnabled())));
|
|
1705
2278
|
break;
|
|
1706
2279
|
case "version":
|
|
1707
2280
|
printVersion();
|
|
2281
|
+
await printLatestHint();
|
|
1708
2282
|
break;
|
|
1709
2283
|
case "error":
|
|
1710
2284
|
console.error(message(cmd.message));
|