ymmv-cli 0.6.2 → 0.7.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/dist/cli.js +99 -37
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/index.ts
|
|
4
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
5
|
-
|
|
6
|
-
// src/config.ts
|
|
7
|
-
var BASE = (process.env.YMMV_API ?? "https://ymmv.fyi").replace(/\/+$/, "");
|
|
8
|
-
|
|
9
3
|
// ../shared/dist/keys.js
|
|
10
4
|
var CURATED_KEYS = [
|
|
11
5
|
"editor",
|
|
@@ -343,6 +337,9 @@ var RESERVED_ROUTES = ["404", "api", "login", "logout"];
|
|
|
343
337
|
var CLI_VERBS = ["login", "logout", "set", "unset", "delete", "view", "help"];
|
|
344
338
|
var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
|
|
345
339
|
var RESERVED_SET = new Set(RESERVED);
|
|
340
|
+
function isReserved(handle) {
|
|
341
|
+
return RESERVED_SET.has(handle.toLowerCase());
|
|
342
|
+
}
|
|
346
343
|
var HANDLE_RE = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$/;
|
|
347
344
|
function isValidHandle(handle) {
|
|
348
345
|
return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
|
|
@@ -526,6 +523,11 @@ function notFound(handle, color, base) {
|
|
|
526
523
|
}
|
|
527
524
|
|
|
528
525
|
// src/http.ts
|
|
526
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
527
|
+
var TIMEOUT_TEXT = "request timed out";
|
|
528
|
+
function isTimeoutError(err) {
|
|
529
|
+
return err instanceof Error && (err.name === "TimeoutError" || err.cause instanceof Error && err.cause.name === "TimeoutError");
|
|
530
|
+
}
|
|
529
531
|
function causeText(err) {
|
|
530
532
|
const pick = (e) => {
|
|
531
533
|
if (e instanceof AggregateError) {
|
|
@@ -536,6 +538,7 @@ function causeText(err) {
|
|
|
536
538
|
}
|
|
537
539
|
return e.message;
|
|
538
540
|
};
|
|
541
|
+
if (isTimeoutError(err)) return TIMEOUT_TEXT;
|
|
539
542
|
let text;
|
|
540
543
|
if (err instanceof Error) {
|
|
541
544
|
const fromCause = err.cause instanceof Error ? pick(err.cause) : "";
|
|
@@ -549,9 +552,34 @@ function wireText(text) {
|
|
|
549
552
|
const clean = sanitizeValue(String(text));
|
|
550
553
|
return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
|
|
551
554
|
}
|
|
555
|
+
var INVISIBLE_RE = new RegExp("\\p{Default_Ignorable_Code_Point}", "gu");
|
|
556
|
+
async function serverMessage(res) {
|
|
557
|
+
try {
|
|
558
|
+
const body = await res.json();
|
|
559
|
+
if (typeof body?.message === "string") {
|
|
560
|
+
const clean = wireText(body.message).trim();
|
|
561
|
+
if (clean.replace(INVISIBLE_RE, "").trim()) return clean;
|
|
562
|
+
}
|
|
563
|
+
} catch (err) {
|
|
564
|
+
if (isTimeoutError(err)) throw err;
|
|
565
|
+
}
|
|
566
|
+
return void 0;
|
|
567
|
+
}
|
|
568
|
+
function withRetryHint(msg, res) {
|
|
569
|
+
const retry = res.headers.get("retry-after");
|
|
570
|
+
return retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg;
|
|
571
|
+
}
|
|
572
|
+
function displayError(err) {
|
|
573
|
+
if (err instanceof Error && err.name === "TimeoutError") return TIMEOUT_TEXT;
|
|
574
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
575
|
+
return text.split(/\r?\n/).map((line) => sanitizeValue(line)).join("\n");
|
|
576
|
+
}
|
|
552
577
|
async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
553
578
|
try {
|
|
554
|
-
return await fetchFn(url,
|
|
579
|
+
return await fetchFn(url, {
|
|
580
|
+
...init,
|
|
581
|
+
signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
582
|
+
});
|
|
555
583
|
} catch (err) {
|
|
556
584
|
throw new Error(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
|
|
557
585
|
cause: err
|
|
@@ -559,7 +587,21 @@ async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
|
559
587
|
}
|
|
560
588
|
}
|
|
561
589
|
|
|
590
|
+
// src/index.ts
|
|
591
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
592
|
+
|
|
593
|
+
// src/config.ts
|
|
594
|
+
var BASE = (process.env.YMMV_API ?? "https://ymmv.fyi").replace(/\/+$/, "");
|
|
595
|
+
|
|
562
596
|
// src/auth-http.ts
|
|
597
|
+
async function bodyJson(res) {
|
|
598
|
+
try {
|
|
599
|
+
return await res.json();
|
|
600
|
+
} catch (err) {
|
|
601
|
+
if (isTimeoutError(err)) throw err;
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
563
605
|
async function mintYmmvToken(accessToken) {
|
|
564
606
|
const res = await safeFetch(
|
|
565
607
|
`${BASE}/api/v1/auth/token`,
|
|
@@ -575,20 +617,23 @@ async function mintYmmvToken(accessToken) {
|
|
|
575
617
|
BASE
|
|
576
618
|
);
|
|
577
619
|
if (!res.ok) {
|
|
578
|
-
const body = await res.json().catch(() => ({}));
|
|
579
620
|
if (res.status === 503) {
|
|
580
621
|
throw new Error(
|
|
581
|
-
|
|
622
|
+
await serverMessage(res) ?? "GitHub is unavailable. Run `ymmv login` again shortly."
|
|
582
623
|
);
|
|
583
624
|
}
|
|
584
625
|
if (res.status === 429) {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
626
|
+
throw new Error(
|
|
627
|
+
withRetryHint(
|
|
628
|
+
await serverMessage(res) ?? "Too many login attempts. Slow down and try again shortly",
|
|
629
|
+
res
|
|
630
|
+
)
|
|
631
|
+
);
|
|
588
632
|
}
|
|
633
|
+
const body = await bodyJson(res) ?? {};
|
|
589
634
|
throw new Error(`login failed: ${res.status} ${wireText(body.error ?? "")}`.trim());
|
|
590
635
|
}
|
|
591
|
-
const data = await res
|
|
636
|
+
const data = await bodyJson(res);
|
|
592
637
|
if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
|
|
593
638
|
throw new Error(
|
|
594
639
|
`Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`
|
|
@@ -597,16 +642,23 @@ async function mintYmmvToken(accessToken) {
|
|
|
597
642
|
return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
|
|
598
643
|
}
|
|
599
644
|
async function revokeYmmvToken(token) {
|
|
600
|
-
const res = await
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
645
|
+
const res = await safeFetch(
|
|
646
|
+
`${BASE}/api/v1/auth/logout`,
|
|
647
|
+
{
|
|
648
|
+
method: "POST",
|
|
649
|
+
headers: { authorization: `Bearer ${token}` },
|
|
650
|
+
// Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete
|
|
651
|
+
// the local file while the server token stays live). Same guard as mint + publish/delete.
|
|
652
|
+
redirect: "manual"
|
|
653
|
+
},
|
|
654
|
+
BASE
|
|
655
|
+
);
|
|
607
656
|
if (!res.ok) throw new Error(`logout failed: ${res.status}`);
|
|
608
|
-
const body = await res
|
|
609
|
-
|
|
657
|
+
const body = await bodyJson(res);
|
|
658
|
+
if (!body || typeof body.revoked !== "boolean") {
|
|
659
|
+
throw new Error("logout failed: unexpected response");
|
|
660
|
+
}
|
|
661
|
+
return body.revoked;
|
|
610
662
|
}
|
|
611
663
|
|
|
612
664
|
// src/commands.ts
|
|
@@ -685,7 +737,10 @@ async function requestDeviceCode(deps = {}) {
|
|
|
685
737
|
if (!res.ok) {
|
|
686
738
|
throw new Error(`device code request failed: ${res.status} ${wireText(await res.text())}`);
|
|
687
739
|
}
|
|
688
|
-
const data = await res.json().catch(() =>
|
|
740
|
+
const data = await res.json().catch((err) => {
|
|
741
|
+
if (isTimeoutError(err)) throw err;
|
|
742
|
+
return null;
|
|
743
|
+
});
|
|
689
744
|
if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.expires_in !== "number") {
|
|
690
745
|
throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
|
|
691
746
|
}
|
|
@@ -702,6 +757,7 @@ async function pollForToken(dc, deps = {}) {
|
|
|
702
757
|
const MAX_TRANSIENT_FAILURES = 5;
|
|
703
758
|
while (now() < deadline) {
|
|
704
759
|
await sleep(interval * 1e3);
|
|
760
|
+
if (now() >= deadline) break;
|
|
705
761
|
let res;
|
|
706
762
|
try {
|
|
707
763
|
res = await doFetch(TOKEN_URL, {
|
|
@@ -711,7 +767,12 @@ async function pollForToken(dc, deps = {}) {
|
|
|
711
767
|
client_id: GITHUB_CLIENT_ID,
|
|
712
768
|
device_code: dc.device_code,
|
|
713
769
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
714
|
-
})
|
|
770
|
+
}),
|
|
771
|
+
// GitHub answers this endpoint immediately — slow_down pacing lives in the sleep above,
|
|
772
|
+
// never inside a held request — so the per-request timeout can't cut pacing short. It only
|
|
773
|
+
// turns a HUNG poll into a TimeoutError feeding the transient counter (previously a hang
|
|
774
|
+
// here stalled the login forever: the deadline is only checked between iterations).
|
|
775
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
715
776
|
});
|
|
716
777
|
} catch (err) {
|
|
717
778
|
lastCause = causeText(err);
|
|
@@ -720,9 +781,9 @@ async function pollForToken(dc, deps = {}) {
|
|
|
720
781
|
if (res?.ok) {
|
|
721
782
|
try {
|
|
722
783
|
tok = await res.json();
|
|
723
|
-
} catch {
|
|
784
|
+
} catch (err) {
|
|
724
785
|
tok = void 0;
|
|
725
|
-
lastCause = "unexpected response body";
|
|
786
|
+
lastCause = isTimeoutError(err) ? causeText(err) : "unexpected response body";
|
|
726
787
|
}
|
|
727
788
|
} else if (res) {
|
|
728
789
|
lastCause = `HTTP ${res.status}`;
|
|
@@ -787,14 +848,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
787
848
|
|
|
788
849
|
// src/api.ts
|
|
789
850
|
async function rateLimitMessage(res) {
|
|
790
|
-
|
|
791
|
-
let msg = "rate limited, too many requests";
|
|
792
|
-
try {
|
|
793
|
-
const body = await res.json();
|
|
794
|
-
if (typeof body?.message === "string" && body.message) msg = wireText(body.message);
|
|
795
|
-
} catch {
|
|
796
|
-
}
|
|
797
|
-
return retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg;
|
|
851
|
+
return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
|
|
798
852
|
}
|
|
799
853
|
async function ensureLogin() {
|
|
800
854
|
const existing = await loadToken();
|
|
@@ -840,7 +894,7 @@ async function publishProfile(profile) {
|
|
|
840
894
|
if (res.status === 401) throw new Error("Authentication failed. Run `ymmv login`.");
|
|
841
895
|
if (res.status === 409) {
|
|
842
896
|
throw new Error(
|
|
843
|
-
"that handle is taken by another account (your GitHub handle may have been reused)."
|
|
897
|
+
await serverMessage(res) ?? "that handle is taken by another account (your GitHub handle may have been reused)."
|
|
844
898
|
);
|
|
845
899
|
}
|
|
846
900
|
}
|
|
@@ -1536,6 +1590,7 @@ function resolveArg(argv) {
|
|
|
1536
1590
|
if (!isValidHandle(handle)) {
|
|
1537
1591
|
return { kind: "error", message: `"${handle}" is not a valid GitHub handle.` };
|
|
1538
1592
|
}
|
|
1593
|
+
if (isReserved(handle)) return reservedError(handle);
|
|
1539
1594
|
return { kind: "view", handle };
|
|
1540
1595
|
}
|
|
1541
1596
|
if (first.startsWith("-")) {
|
|
@@ -1547,8 +1602,15 @@ function resolveArg(argv) {
|
|
|
1547
1602
|
message: `"${first}" is not a valid GitHub handle. Run \`ymmv help\`.`
|
|
1548
1603
|
};
|
|
1549
1604
|
}
|
|
1605
|
+
if (isReserved(first)) return reservedError(first);
|
|
1550
1606
|
return { kind: "view", handle: first };
|
|
1551
1607
|
}
|
|
1608
|
+
function reservedError(handle) {
|
|
1609
|
+
return {
|
|
1610
|
+
kind: "error",
|
|
1611
|
+
message: `"${handle}" is a reserved name; it can't have a profile.`
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1552
1614
|
|
|
1553
1615
|
// src/index.ts
|
|
1554
1616
|
var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
|
|
@@ -1557,7 +1619,7 @@ ${c.faint}Usage:${c.reset}
|
|
|
1557
1619
|
ymmv detect your stack, confirm, and publish your profile
|
|
1558
1620
|
ymmv -y publish without prompts (required when stdin isn't a TTY)
|
|
1559
1621
|
ymmv <handle> view a profile; logged in, see the diff vs yours
|
|
1560
|
-
ymmv view <handle> explicit view (
|
|
1622
|
+
ymmv view <handle> explicit view (same as ymmv <handle>)
|
|
1561
1623
|
ymmv set <key> <value> set one curated key
|
|
1562
1624
|
ymmv set --extra "L=V" set a free-form extra
|
|
1563
1625
|
ymmv unset <key> remove one curated key (ymmv set <key> - works too)
|
|
@@ -1653,7 +1715,7 @@ async function main(argv) {
|
|
|
1653
1715
|
|
|
1654
1716
|
// src/cli.ts
|
|
1655
1717
|
main(process.argv.slice(2)).catch((err) => {
|
|
1656
|
-
console.error(message(
|
|
1718
|
+
console.error(message(displayError(err)));
|
|
1657
1719
|
process.exitCode = 1;
|
|
1658
1720
|
}).finally(() => {
|
|
1659
1721
|
(process.exitCode ? process.stderr : process.stdout).write("\n");
|