ymmv-cli 0.6.1 → 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/README.md +2 -2
- package/dist/cli.js +109 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# ymmv-cli
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**The tools you actually use. Publish from the CLI, diff against anyone's.**
|
|
4
4
|
|
|
5
|
-
Editor, OS, shell, terminal, theme
|
|
5
|
+
Editor, OS, shell, terminal, theme (and more), published to a
|
|
6
6
|
clean page at `ymmv.fyi/<handle>` in about 10 seconds. See a live one:
|
|
7
7
|
[ymmv.fyi/bardisty](https://ymmv.fyi/bardisty).
|
|
8
8
|
|
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",
|
|
@@ -286,8 +280,14 @@ function parseProfile(raw) {
|
|
|
286
280
|
}
|
|
287
281
|
if (typeof raw.handle !== "string")
|
|
288
282
|
throw new ProfileParseError("handle is not a string");
|
|
283
|
+
if (raw.handle.length > MAX_PARSE_LABEL) {
|
|
284
|
+
throw new ProfileParseError(`handle exceeds ${MAX_PARSE_LABEL} chars`);
|
|
285
|
+
}
|
|
289
286
|
if (typeof raw.updated_at !== "string")
|
|
290
287
|
throw new ProfileParseError("updated_at is not a string");
|
|
288
|
+
if (raw.updated_at.length > MAX_PARSE_LABEL) {
|
|
289
|
+
throw new ProfileParseError(`updated_at exceeds ${MAX_PARSE_LABEL} chars`);
|
|
290
|
+
}
|
|
291
291
|
if (!Array.isArray(raw.entries))
|
|
292
292
|
throw new ProfileParseError("entries is not an array");
|
|
293
293
|
if (raw.entries.length > MAX_PARSE_ENTRIES) {
|
|
@@ -299,6 +299,9 @@ function parseProfile(raw) {
|
|
|
299
299
|
if (!isRecord(entry) || typeof entry.key !== "string" || typeof entry.value !== "string") {
|
|
300
300
|
throw new ProfileParseError(`entry ${i} is not {key,value} strings`);
|
|
301
301
|
}
|
|
302
|
+
if (entry.key.length > MAX_PARSE_LABEL) {
|
|
303
|
+
throw new ProfileParseError(`entry ${i} key exceeds ${MAX_PARSE_LABEL} chars`);
|
|
304
|
+
}
|
|
302
305
|
if (entry.value.length > MAX_PARSE_VALUE) {
|
|
303
306
|
throw new ProfileParseError(`entry ${i} value exceeds ${MAX_PARSE_VALUE} chars`);
|
|
304
307
|
}
|
|
@@ -330,10 +333,13 @@ function parseProfile(raw) {
|
|
|
330
333
|
}
|
|
331
334
|
|
|
332
335
|
// ../shared/dist/reserved.js
|
|
333
|
-
var RESERVED_ROUTES = ["api", "login", "logout"];
|
|
336
|
+
var RESERVED_ROUTES = ["404", "api", "login", "logout"];
|
|
334
337
|
var CLI_VERBS = ["login", "logout", "set", "unset", "delete", "view", "help"];
|
|
335
338
|
var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
|
|
336
339
|
var RESERVED_SET = new Set(RESERVED);
|
|
340
|
+
function isReserved(handle) {
|
|
341
|
+
return RESERVED_SET.has(handle.toLowerCase());
|
|
342
|
+
}
|
|
337
343
|
var HANDLE_RE = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$/;
|
|
338
344
|
function isValidHandle(handle) {
|
|
339
345
|
return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
|
|
@@ -517,6 +523,11 @@ function notFound(handle, color, base) {
|
|
|
517
523
|
}
|
|
518
524
|
|
|
519
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
|
+
}
|
|
520
531
|
function causeText(err) {
|
|
521
532
|
const pick = (e) => {
|
|
522
533
|
if (e instanceof AggregateError) {
|
|
@@ -527,6 +538,7 @@ function causeText(err) {
|
|
|
527
538
|
}
|
|
528
539
|
return e.message;
|
|
529
540
|
};
|
|
541
|
+
if (isTimeoutError(err)) return TIMEOUT_TEXT;
|
|
530
542
|
let text;
|
|
531
543
|
if (err instanceof Error) {
|
|
532
544
|
const fromCause = err.cause instanceof Error ? pick(err.cause) : "";
|
|
@@ -540,9 +552,34 @@ function wireText(text) {
|
|
|
540
552
|
const clean = sanitizeValue(String(text));
|
|
541
553
|
return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
|
|
542
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
|
+
}
|
|
543
577
|
async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
544
578
|
try {
|
|
545
|
-
return await fetchFn(url,
|
|
579
|
+
return await fetchFn(url, {
|
|
580
|
+
...init,
|
|
581
|
+
signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
582
|
+
});
|
|
546
583
|
} catch (err) {
|
|
547
584
|
throw new Error(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
|
|
548
585
|
cause: err
|
|
@@ -550,7 +587,21 @@ async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
|
550
587
|
}
|
|
551
588
|
}
|
|
552
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
|
+
|
|
553
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
|
+
}
|
|
554
605
|
async function mintYmmvToken(accessToken) {
|
|
555
606
|
const res = await safeFetch(
|
|
556
607
|
`${BASE}/api/v1/auth/token`,
|
|
@@ -566,20 +617,23 @@ async function mintYmmvToken(accessToken) {
|
|
|
566
617
|
BASE
|
|
567
618
|
);
|
|
568
619
|
if (!res.ok) {
|
|
569
|
-
const body = await res.json().catch(() => ({}));
|
|
570
620
|
if (res.status === 503) {
|
|
571
621
|
throw new Error(
|
|
572
|
-
|
|
622
|
+
await serverMessage(res) ?? "GitHub is unavailable. Run `ymmv login` again shortly."
|
|
573
623
|
);
|
|
574
624
|
}
|
|
575
625
|
if (res.status === 429) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
626
|
+
throw new Error(
|
|
627
|
+
withRetryHint(
|
|
628
|
+
await serverMessage(res) ?? "Too many login attempts. Slow down and try again shortly",
|
|
629
|
+
res
|
|
630
|
+
)
|
|
631
|
+
);
|
|
579
632
|
}
|
|
633
|
+
const body = await bodyJson(res) ?? {};
|
|
580
634
|
throw new Error(`login failed: ${res.status} ${wireText(body.error ?? "")}`.trim());
|
|
581
635
|
}
|
|
582
|
-
const data = await res
|
|
636
|
+
const data = await bodyJson(res);
|
|
583
637
|
if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
|
|
584
638
|
throw new Error(
|
|
585
639
|
`Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`
|
|
@@ -588,16 +642,23 @@ async function mintYmmvToken(accessToken) {
|
|
|
588
642
|
return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
|
|
589
643
|
}
|
|
590
644
|
async function revokeYmmvToken(token) {
|
|
591
|
-
const res = await
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
+
);
|
|
598
656
|
if (!res.ok) throw new Error(`logout failed: ${res.status}`);
|
|
599
|
-
const body = await res
|
|
600
|
-
|
|
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;
|
|
601
662
|
}
|
|
602
663
|
|
|
603
664
|
// src/commands.ts
|
|
@@ -676,7 +737,10 @@ async function requestDeviceCode(deps = {}) {
|
|
|
676
737
|
if (!res.ok) {
|
|
677
738
|
throw new Error(`device code request failed: ${res.status} ${wireText(await res.text())}`);
|
|
678
739
|
}
|
|
679
|
-
const data = await res.json().catch(() =>
|
|
740
|
+
const data = await res.json().catch((err) => {
|
|
741
|
+
if (isTimeoutError(err)) throw err;
|
|
742
|
+
return null;
|
|
743
|
+
});
|
|
680
744
|
if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.expires_in !== "number") {
|
|
681
745
|
throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
|
|
682
746
|
}
|
|
@@ -693,6 +757,7 @@ async function pollForToken(dc, deps = {}) {
|
|
|
693
757
|
const MAX_TRANSIENT_FAILURES = 5;
|
|
694
758
|
while (now() < deadline) {
|
|
695
759
|
await sleep(interval * 1e3);
|
|
760
|
+
if (now() >= deadline) break;
|
|
696
761
|
let res;
|
|
697
762
|
try {
|
|
698
763
|
res = await doFetch(TOKEN_URL, {
|
|
@@ -702,7 +767,12 @@ async function pollForToken(dc, deps = {}) {
|
|
|
702
767
|
client_id: GITHUB_CLIENT_ID,
|
|
703
768
|
device_code: dc.device_code,
|
|
704
769
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
705
|
-
})
|
|
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)
|
|
706
776
|
});
|
|
707
777
|
} catch (err) {
|
|
708
778
|
lastCause = causeText(err);
|
|
@@ -711,9 +781,9 @@ async function pollForToken(dc, deps = {}) {
|
|
|
711
781
|
if (res?.ok) {
|
|
712
782
|
try {
|
|
713
783
|
tok = await res.json();
|
|
714
|
-
} catch {
|
|
784
|
+
} catch (err) {
|
|
715
785
|
tok = void 0;
|
|
716
|
-
lastCause = "unexpected response body";
|
|
786
|
+
lastCause = isTimeoutError(err) ? causeText(err) : "unexpected response body";
|
|
717
787
|
}
|
|
718
788
|
} else if (res) {
|
|
719
789
|
lastCause = `HTTP ${res.status}`;
|
|
@@ -778,14 +848,7 @@ ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}`
|
|
|
778
848
|
|
|
779
849
|
// src/api.ts
|
|
780
850
|
async function rateLimitMessage(res) {
|
|
781
|
-
|
|
782
|
-
let msg = "rate limited, too many requests";
|
|
783
|
-
try {
|
|
784
|
-
const body = await res.json();
|
|
785
|
-
if (typeof body?.message === "string" && body.message) msg = wireText(body.message);
|
|
786
|
-
} catch {
|
|
787
|
-
}
|
|
788
|
-
return retry && /^\d+$/.test(retry) ? `${msg} (retry in ${retry}s)` : msg;
|
|
851
|
+
return withRetryHint(await serverMessage(res) ?? "rate limited, too many requests", res);
|
|
789
852
|
}
|
|
790
853
|
async function ensureLogin() {
|
|
791
854
|
const existing = await loadToken();
|
|
@@ -831,7 +894,7 @@ async function publishProfile(profile) {
|
|
|
831
894
|
if (res.status === 401) throw new Error("Authentication failed. Run `ymmv login`.");
|
|
832
895
|
if (res.status === 409) {
|
|
833
896
|
throw new Error(
|
|
834
|
-
"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)."
|
|
835
898
|
);
|
|
836
899
|
}
|
|
837
900
|
}
|
|
@@ -1527,6 +1590,7 @@ function resolveArg(argv) {
|
|
|
1527
1590
|
if (!isValidHandle(handle)) {
|
|
1528
1591
|
return { kind: "error", message: `"${handle}" is not a valid GitHub handle.` };
|
|
1529
1592
|
}
|
|
1593
|
+
if (isReserved(handle)) return reservedError(handle);
|
|
1530
1594
|
return { kind: "view", handle };
|
|
1531
1595
|
}
|
|
1532
1596
|
if (first.startsWith("-")) {
|
|
@@ -1538,8 +1602,15 @@ function resolveArg(argv) {
|
|
|
1538
1602
|
message: `"${first}" is not a valid GitHub handle. Run \`ymmv help\`.`
|
|
1539
1603
|
};
|
|
1540
1604
|
}
|
|
1605
|
+
if (isReserved(first)) return reservedError(first);
|
|
1541
1606
|
return { kind: "view", handle: first };
|
|
1542
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
|
+
}
|
|
1543
1614
|
|
|
1544
1615
|
// src/index.ts
|
|
1545
1616
|
var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
|
|
@@ -1548,7 +1619,7 @@ ${c.faint}Usage:${c.reset}
|
|
|
1548
1619
|
ymmv detect your stack, confirm, and publish your profile
|
|
1549
1620
|
ymmv -y publish without prompts (required when stdin isn't a TTY)
|
|
1550
1621
|
ymmv <handle> view a profile; logged in, see the diff vs yours
|
|
1551
|
-
ymmv view <handle> explicit view (
|
|
1622
|
+
ymmv view <handle> explicit view (same as ymmv <handle>)
|
|
1552
1623
|
ymmv set <key> <value> set one curated key
|
|
1553
1624
|
ymmv set --extra "L=V" set a free-form extra
|
|
1554
1625
|
ymmv unset <key> remove one curated key (ymmv set <key> - works too)
|
|
@@ -1644,7 +1715,7 @@ async function main(argv) {
|
|
|
1644
1715
|
|
|
1645
1716
|
// src/cli.ts
|
|
1646
1717
|
main(process.argv.slice(2)).catch((err) => {
|
|
1647
|
-
console.error(message(
|
|
1718
|
+
console.error(message(displayError(err)));
|
|
1648
1719
|
process.exitCode = 1;
|
|
1649
1720
|
}).finally(() => {
|
|
1650
1721
|
(process.exitCode ? process.stderr : process.stdout).write("\n");
|