ymmv-cli 0.4.0 → 0.6.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 +34 -12
- package/dist/cli.js +781 -310
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,59 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { readFileSync } from "fs";
|
|
4
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
5
5
|
|
|
6
6
|
// src/config.ts
|
|
7
7
|
var BASE = (process.env.YMMV_API ?? "https://ymmv.fyi").replace(/\/+$/, "");
|
|
8
8
|
|
|
9
|
-
// src/auth-http.ts
|
|
10
|
-
async function mintYmmvToken(accessToken) {
|
|
11
|
-
const res = await fetch(`${BASE}/api/v1/auth/token`, {
|
|
12
|
-
method: "POST",
|
|
13
|
-
headers: { "content-type": "application/json" },
|
|
14
|
-
body: JSON.stringify({ access_token: accessToken }),
|
|
15
|
-
// Never follow a redirect: a 30x must fail (the existing `!res.ok` guard rejects the resulting
|
|
16
|
-
// opaqueredirect), not re-POST the GitHub access_token to the redirect target or read a
|
|
17
|
-
// redirected 200 as a successful mint. Mirrors publish/delete in api.ts.
|
|
18
|
-
redirect: "manual"
|
|
19
|
-
});
|
|
20
|
-
if (!res.ok) {
|
|
21
|
-
const body = await res.json().catch(() => ({}));
|
|
22
|
-
if (res.status === 503) {
|
|
23
|
-
throw new Error(body.message ?? "GitHub is unavailable \u2014 run `ymmv login` again shortly.");
|
|
24
|
-
}
|
|
25
|
-
if (res.status === 429) {
|
|
26
|
-
const retry = res.headers.get("retry-after");
|
|
27
|
-
const msg = body.message ?? "too many login attempts \u2014 slow down and try again shortly";
|
|
28
|
-
throw new Error(retry ? `${msg} (retry in ${retry}s)` : msg);
|
|
29
|
-
}
|
|
30
|
-
throw new Error(`login failed: ${res.status} ${body.error ?? ""}`.trim());
|
|
31
|
-
}
|
|
32
|
-
return await res.json();
|
|
33
|
-
}
|
|
34
|
-
async function revokeYmmvToken(token) {
|
|
35
|
-
const res = await fetch(`${BASE}/api/v1/auth/logout`, {
|
|
36
|
-
method: "POST",
|
|
37
|
-
headers: { authorization: `Bearer ${token}` },
|
|
38
|
-
// Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete the
|
|
39
|
-
// local file while the server token stays live). Same guard as mint + publish/delete.
|
|
40
|
-
redirect: "manual"
|
|
41
|
-
});
|
|
42
|
-
if (!res.ok) throw new Error(`logout failed: ${res.status}`);
|
|
43
|
-
const body = await res.json().catch(() => ({}));
|
|
44
|
-
return body.revoked === true;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
9
|
// ../shared/dist/keys.js
|
|
48
10
|
var CURATED_KEYS = [
|
|
49
11
|
"editor",
|
|
50
12
|
"os",
|
|
51
13
|
"shell",
|
|
14
|
+
"prompt",
|
|
52
15
|
"terminal",
|
|
53
16
|
"browser",
|
|
54
17
|
"window-manager",
|
|
55
18
|
"font",
|
|
19
|
+
"theme",
|
|
56
20
|
"multiplexer",
|
|
21
|
+
"version-manager",
|
|
57
22
|
"dotfiles",
|
|
58
23
|
"ai-tool"
|
|
59
24
|
];
|
|
@@ -61,11 +26,14 @@ var KEY_LABELS = {
|
|
|
61
26
|
editor: "Editor",
|
|
62
27
|
os: "OS",
|
|
63
28
|
shell: "Shell",
|
|
29
|
+
prompt: "Prompt",
|
|
64
30
|
terminal: "Terminal",
|
|
65
31
|
browser: "Browser",
|
|
66
32
|
"window-manager": "Window Manager",
|
|
67
33
|
font: "Font",
|
|
34
|
+
theme: "Theme",
|
|
68
35
|
multiplexer: "Multiplexer",
|
|
36
|
+
"version-manager": "Version Manager",
|
|
69
37
|
dotfiles: "Dotfiles",
|
|
70
38
|
"ai-tool": "AI Tool"
|
|
71
39
|
};
|
|
@@ -122,16 +90,95 @@ var TOOLS = [
|
|
|
122
90
|
{ key: "terminal", canonical: "Hyper", envTokens: ["hyper"] },
|
|
123
91
|
{ key: "terminal", canonical: "Rio", envTokens: ["rio"] },
|
|
124
92
|
{ key: "terminal", canonical: "kitty", envTokens: ["kitty"] },
|
|
93
|
+
// kitty refuses TERM_PROGRAM (its issue #3317); detect.ts probes KITTY_WINDOW_ID instead — token kept for the day that changes
|
|
125
94
|
{ key: "terminal", canonical: "Tabby", envTokens: ["tabby"] },
|
|
126
95
|
{ key: "terminal", canonical: "Warp", envTokens: ["warpterminal"] },
|
|
127
96
|
// `warpterminal` env id ≠ typed "Warp"
|
|
97
|
+
{ key: "terminal", canonical: "GNOME Console", envTokens: ["kgx"] },
|
|
98
|
+
// $TERM_PROGRAM=kgx since GNOME 45
|
|
99
|
+
{ key: "terminal", canonical: "mintty", envTokens: ["mintty"] },
|
|
100
|
+
// Git Bash/MSYS2 console, ≥3.1.5
|
|
101
|
+
{ key: "terminal", canonical: "Zed", envTokens: ["zed"] },
|
|
102
|
+
// Zed's built-in terminal (~0.145+)
|
|
103
|
+
// ── browser (envTokens mirror BROWSER_NAMES: $BROWSER basenames) ──────────
|
|
104
|
+
{ key: "browser", canonical: "Firefox", envTokens: ["firefox"], aliases: ["ff"] },
|
|
105
|
+
// Hyphenated Linux binary names double as diff aliases (kde/plasma precedent): unlike `vi`,
|
|
106
|
+
// each unambiguously names exactly that browser, so a TYPED "google-chrome" should diff-equal
|
|
107
|
+
// a DETECTED "Chrome".
|
|
108
|
+
{
|
|
109
|
+
key: "browser",
|
|
110
|
+
canonical: "Chrome",
|
|
111
|
+
envTokens: ["chrome", "google-chrome"],
|
|
112
|
+
aliases: ["google chrome", "google-chrome"]
|
|
113
|
+
// NOT Chromium — distinct browser
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
key: "browser",
|
|
117
|
+
canonical: "Edge",
|
|
118
|
+
envTokens: ["msedge", "microsoft-edge"],
|
|
119
|
+
aliases: ["microsoft edge", "microsoft-edge"]
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
key: "browser",
|
|
123
|
+
canonical: "Chromium",
|
|
124
|
+
envTokens: ["chromium", "chromium-browser"],
|
|
125
|
+
aliases: ["chromium-browser"]
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: "browser",
|
|
129
|
+
canonical: "Brave",
|
|
130
|
+
envTokens: ["brave", "brave-browser"],
|
|
131
|
+
aliases: ["brave-browser"]
|
|
132
|
+
},
|
|
133
|
+
{ key: "browser", canonical: "LibreWolf", envTokens: ["librewolf"] },
|
|
134
|
+
{
|
|
135
|
+
key: "browser",
|
|
136
|
+
canonical: "Vivaldi",
|
|
137
|
+
envTokens: ["vivaldi", "vivaldi-stable"],
|
|
138
|
+
aliases: ["vivaldi-stable"]
|
|
139
|
+
},
|
|
140
|
+
{ key: "browser", canonical: "Zen", envTokens: ["zen", "zen-browser"], aliases: ["zen-browser"] },
|
|
141
|
+
{ key: "browser", canonical: "qutebrowser", envTokens: ["qutebrowser"] },
|
|
142
|
+
// ── window-manager (envTokens mirror WM_NAMES: XDG_CURRENT_DESKTOP list entries +
|
|
143
|
+
// DESKTOP_SESSION basenames, lowercased). Deliberate: canonical names are what users put on
|
|
144
|
+
// profiles — the DE name (GNOME, KDE Plasma), not its compositor (Mutter, KWin). Compositors
|
|
145
|
+
// with their own socket vars (Hyprland/Sway/i3/niri) are ALSO probed directly in detect.ts;
|
|
146
|
+
// WMs with no env footprint at all (river, bspwm, dwm, Qtile, awesome) have no tokens — a
|
|
147
|
+
// dead token would only inflate the pinned detector fixtures. ──────────
|
|
148
|
+
{ key: "window-manager", canonical: "GNOME", envTokens: ["gnome"] },
|
|
149
|
+
{
|
|
150
|
+
key: "window-manager",
|
|
151
|
+
canonical: "KDE Plasma",
|
|
152
|
+
envTokens: ["kde", "plasma"],
|
|
153
|
+
aliases: ["kde", "plasma"]
|
|
154
|
+
},
|
|
155
|
+
{ key: "window-manager", canonical: "Hyprland", envTokens: ["hyprland"] },
|
|
156
|
+
{ key: "window-manager", canonical: "Sway", envTokens: ["sway"] },
|
|
157
|
+
{ key: "window-manager", canonical: "i3", envTokens: ["i3"] },
|
|
158
|
+
{ key: "window-manager", canonical: "niri", envTokens: ["niri"] },
|
|
159
|
+
{ key: "window-manager", canonical: "COSMIC", envTokens: ["cosmic"] },
|
|
160
|
+
{ key: "window-manager", canonical: "Cinnamon", envTokens: ["cinnamon", "x-cinnamon"] },
|
|
161
|
+
{ key: "window-manager", canonical: "MATE", envTokens: ["mate"] },
|
|
162
|
+
{ key: "window-manager", canonical: "XFCE", envTokens: ["xfce"] },
|
|
163
|
+
{ key: "window-manager", canonical: "LXQt", envTokens: ["lxqt"] },
|
|
164
|
+
{ key: "window-manager", canonical: "Budgie", envTokens: ["budgie"] },
|
|
165
|
+
{ key: "window-manager", canonical: "Pantheon", envTokens: ["pantheon"] },
|
|
166
|
+
{ key: "window-manager", canonical: "Unity", envTokens: ["unity"] },
|
|
167
|
+
// ── prompt (presence-detected in detect.ts — entries exist for diff aliases only; fold
|
|
168
|
+
// already equates case/space variants, so single-word tools need no entry) ──
|
|
169
|
+
{ key: "prompt", canonical: "Powerlevel10k", aliases: ["p10k"] },
|
|
170
|
+
// NOT p9k — distinct predecessor
|
|
171
|
+
{ key: "prompt", canonical: "Oh My Posh", aliases: ["oh-my-posh", "omp"] },
|
|
172
|
+
// hyphens survive fold
|
|
173
|
+
// ── theme (never detected — aliases for spellings fold can't equate: hyphens + the é) ──
|
|
174
|
+
{ key: "theme", canonical: "Ros\xE9 Pine", aliases: ["rose pine", "rose-pine"] },
|
|
175
|
+
{ key: "theme", canonical: "Tokyo Night", aliases: ["tokyo-night"] },
|
|
176
|
+
{ key: "theme", canonical: "One Dark", aliases: ["one-dark"] },
|
|
128
177
|
// ── diff-only synonyms (no detector for these fields) ─────────────────────
|
|
129
|
-
{ key: "browser", canonical: "Firefox", aliases: ["ff"] },
|
|
130
|
-
{ key: "browser", canonical: "Chrome", aliases: ["google chrome"] },
|
|
131
|
-
// NOT Chromium — distinct browser
|
|
132
|
-
{ key: "browser", canonical: "Edge", aliases: ["microsoft edge"] },
|
|
133
178
|
{ key: "os", canonical: "macOS", aliases: ["osx", "os x", "mac"] },
|
|
134
179
|
{ key: "os", canonical: "Windows", aliases: ["win"] },
|
|
180
|
+
{ key: "os", canonical: "Arch Linux", aliases: ["arch"] },
|
|
181
|
+
// os-release NAME= vs what people type
|
|
135
182
|
{ key: "multiplexer", canonical: "GNU Screen", aliases: ["screen"] }
|
|
136
183
|
// matches detector's "GNU Screen"
|
|
137
184
|
];
|
|
@@ -292,6 +339,269 @@ function isValidHandle(handle) {
|
|
|
292
339
|
return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
|
|
293
340
|
}
|
|
294
341
|
|
|
342
|
+
// src/render.ts
|
|
343
|
+
var ESC = String.fromCharCode(27);
|
|
344
|
+
var CSI = `${ESC}[`;
|
|
345
|
+
var CODES = {
|
|
346
|
+
amber: `${CSI}93m`,
|
|
347
|
+
// DESIGN: amber == ANSI bright-yellow
|
|
348
|
+
faint: `${CSI}90m`,
|
|
349
|
+
bold: `${CSI}1m`,
|
|
350
|
+
reset: `${CSI}0m`
|
|
351
|
+
};
|
|
352
|
+
var NO_CODES = { amber: "", faint: "", bold: "", reset: "" };
|
|
353
|
+
function palette(color) {
|
|
354
|
+
return color ? CODES : NO_CODES;
|
|
355
|
+
}
|
|
356
|
+
var ESC_INTRODUCERS = `${String.fromCharCode(27)}${String.fromCharCode(155)}`;
|
|
357
|
+
var BEL = String.fromCharCode(7);
|
|
358
|
+
var ANSI_RE = new RegExp(
|
|
359
|
+
`[${ESC_INTRODUCERS}][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?${BEL})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`,
|
|
360
|
+
"g"
|
|
361
|
+
);
|
|
362
|
+
var CTRL_RE = new RegExp(
|
|
363
|
+
`[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
|
|
364
|
+
"g"
|
|
365
|
+
);
|
|
366
|
+
var BIDI_RE = new RegExp(
|
|
367
|
+
`[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
|
|
368
|
+
"g"
|
|
369
|
+
);
|
|
370
|
+
function sanitizeValue(value) {
|
|
371
|
+
return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
|
|
372
|
+
}
|
|
373
|
+
function useColor(env, isTTY) {
|
|
374
|
+
if (env.NO_COLOR !== void 0) return false;
|
|
375
|
+
if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
|
|
376
|
+
if (env.TERM === "dumb") return false;
|
|
377
|
+
return isTTY;
|
|
378
|
+
}
|
|
379
|
+
function colorEnabled() {
|
|
380
|
+
return useColor(process.env, Boolean(process.stdout.isTTY));
|
|
381
|
+
}
|
|
382
|
+
var OSC = `${ESC}]`;
|
|
383
|
+
var ST = `${ESC}\\`;
|
|
384
|
+
function displayUrl(value) {
|
|
385
|
+
return value.trim().replace(/^https:\/\/(?=.)/i, "");
|
|
386
|
+
}
|
|
387
|
+
var HTTP_URL_RE = /^https?:\/\/\S+$/i;
|
|
388
|
+
function isHttpUrl(value) {
|
|
389
|
+
return HTTP_URL_RE.test(value.trim());
|
|
390
|
+
}
|
|
391
|
+
var NO_OSC8_TERMS = /* @__PURE__ */ new Set(["linux", "dumb"]);
|
|
392
|
+
function link(url, color, term = process.env.TERM) {
|
|
393
|
+
const clean = sanitizeValue(url).trim();
|
|
394
|
+
if (!color) return clean;
|
|
395
|
+
const text = `${CODES.amber}${displayUrl(clean)}${CODES.reset}`;
|
|
396
|
+
if (term !== void 0 && NO_OSC8_TERMS.has(term)) return text;
|
|
397
|
+
return `${OSC}8;;${clean}${ST}${text}${OSC}8;;${ST}`;
|
|
398
|
+
}
|
|
399
|
+
function relTime(iso, now = Date.now) {
|
|
400
|
+
const t = Date.parse(iso);
|
|
401
|
+
if (Number.isNaN(t)) return sanitizeValue(iso);
|
|
402
|
+
const ms = now() - t;
|
|
403
|
+
if (ms < 6e4) return "just now";
|
|
404
|
+
const m = Math.floor(ms / 6e4);
|
|
405
|
+
if (m < 60) return `${m}m ago`;
|
|
406
|
+
const h = Math.floor(m / 60);
|
|
407
|
+
if (h < 24) return `${h}h ago`;
|
|
408
|
+
const d = Math.floor(h / 24);
|
|
409
|
+
if (d < 30) return `${d}d ago`;
|
|
410
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
411
|
+
}
|
|
412
|
+
function renderProfile(profile, opts) {
|
|
413
|
+
const c = palette(opts.color);
|
|
414
|
+
const preview = opts.mode === "preview";
|
|
415
|
+
const byKey = new Map(
|
|
416
|
+
(profile.entries ?? []).map((e) => [e.key, e.value])
|
|
417
|
+
);
|
|
418
|
+
const rows = CURATED_KEYS.flatMap((key) => {
|
|
419
|
+
const value = byKey.get(key);
|
|
420
|
+
if (value === void 0 && !preview) return [];
|
|
421
|
+
return [{ label: KEY_LABELS[key], value: value === void 0 ? null : sanitizeValue(value) }];
|
|
422
|
+
});
|
|
423
|
+
const extras = (profile.extras ?? []).map((x) => ({
|
|
424
|
+
label: sanitizeValue(x.label),
|
|
425
|
+
value: sanitizeValue(x.value)
|
|
426
|
+
}));
|
|
427
|
+
const labelW = Math.max(
|
|
428
|
+
0,
|
|
429
|
+
...rows.map((r) => r.label.length),
|
|
430
|
+
...extras.map((x) => x.label.length)
|
|
431
|
+
);
|
|
432
|
+
const val = (v) => isHttpUrl(v) ? link(v, opts.color) : v;
|
|
433
|
+
const lines = [
|
|
434
|
+
"",
|
|
435
|
+
` ${c.faint}${opts.site}/${c.reset}${c.bold}${sanitizeValue(profile.handle)}${c.reset}`,
|
|
436
|
+
""
|
|
437
|
+
];
|
|
438
|
+
for (const r of rows) {
|
|
439
|
+
lines.push(
|
|
440
|
+
r.value === null ? ` ${c.faint}${r.label.padEnd(labelW)} ${MISSING}${c.reset}` : ` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${val(r.value)}`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (extras.length) {
|
|
444
|
+
lines.push("");
|
|
445
|
+
for (const x of extras) {
|
|
446
|
+
lines.push(` ${c.faint}${x.label.padEnd(labelW)}${c.reset} ${val(x.value)}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (!preview) {
|
|
450
|
+
lines.push("", ` ${c.faint}updated ${relTime(profile.updated_at, opts.now)}${c.reset}`);
|
|
451
|
+
}
|
|
452
|
+
lines.push("");
|
|
453
|
+
return lines.join("\n");
|
|
454
|
+
}
|
|
455
|
+
var MISSING = "\u2014";
|
|
456
|
+
function extrasBlock(extras, theirsLabel, mineLabel, c) {
|
|
457
|
+
if (!extras.theirs.length && !extras.mine.length) return [];
|
|
458
|
+
const out = ["", ` ${c.faint}extras${c.reset}`];
|
|
459
|
+
const line = (who, label, value) => ` ${c.faint}${who}${c.reset} ${sanitizeValue(label)} = ${sanitizeValue(value)}`;
|
|
460
|
+
for (const x of extras.theirs) out.push(line(theirsLabel, x.label, x.value));
|
|
461
|
+
for (const x of extras.mine) out.push(line(mineLabel, x.label, x.value));
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
function renderDiff(result, opts) {
|
|
465
|
+
const c = palette(opts.color);
|
|
466
|
+
const theirsLabel = sanitizeValue(opts.theirsLabel);
|
|
467
|
+
const mineLabel = sanitizeValue(opts.mineLabel);
|
|
468
|
+
const cells = result.rows.map((r) => ({
|
|
469
|
+
label: r.label,
|
|
470
|
+
theirs: r.theirs === null ? MISSING : sanitizeValue(r.theirs),
|
|
471
|
+
mine: r.mine === null ? MISSING : sanitizeValue(r.mine),
|
|
472
|
+
differ: r.status !== "same"
|
|
473
|
+
}));
|
|
474
|
+
const labelW = Math.max(3, ...cells.map((r) => r.label.length));
|
|
475
|
+
const theirsHead = theirsLabel.toUpperCase();
|
|
476
|
+
const theirsW = Math.max(theirsHead.length, ...cells.map((r) => r.theirs.length));
|
|
477
|
+
const lines = [
|
|
478
|
+
"",
|
|
479
|
+
// The web diff's h1, collapsed to one line — information (which side is which), not decoration,
|
|
480
|
+
// so it prints in both color modes.
|
|
481
|
+
` ${c.faint}how${c.reset} ${c.bold}${theirsLabel}${c.reset} ${c.faint}differs from${c.reset} ${c.bold}${mineLabel}${c.reset}`,
|
|
482
|
+
""
|
|
483
|
+
];
|
|
484
|
+
lines.push(
|
|
485
|
+
` ${c.faint}${"".padEnd(labelW)} ${theirsHead.padEnd(theirsW)} ${mineLabel.toUpperCase()}${c.reset}`
|
|
486
|
+
);
|
|
487
|
+
for (const r of cells) {
|
|
488
|
+
if (!opts.color) {
|
|
489
|
+
const sym = r.differ ? "~" : "=";
|
|
490
|
+
lines.push(`${sym} ${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}`);
|
|
491
|
+
} else if (r.differ) {
|
|
492
|
+
lines.push(
|
|
493
|
+
`${c.amber}\u2022${c.reset} ${r.label.padEnd(labelW)} ${c.amber}${r.theirs.padEnd(theirsW)}${c.reset} ${c.amber}${r.mine}${c.reset}`
|
|
494
|
+
);
|
|
495
|
+
} else {
|
|
496
|
+
lines.push(
|
|
497
|
+
` ${c.faint}${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}${c.reset}`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
lines.push(...extrasBlock(result.extras, theirsLabel, mineLabel, c));
|
|
502
|
+
lines.push("", ` ${c.faint}${result.differ} differ ${result.shared} shared${c.reset}`, "");
|
|
503
|
+
return lines.join("\n");
|
|
504
|
+
}
|
|
505
|
+
function nudge(color) {
|
|
506
|
+
const c = palette(color);
|
|
507
|
+
return `
|
|
508
|
+
${c.amber}publish yours to diff \u2192${c.reset} run ${c.bold}ymmv${c.reset}
|
|
509
|
+
`;
|
|
510
|
+
}
|
|
511
|
+
function notFound(handle, color, base) {
|
|
512
|
+
return `
|
|
513
|
+
no ymmv profile for "${sanitizeValue(handle)}" yet.
|
|
514
|
+
publish one at ${link(base, color)} with: npx ymmv-cli
|
|
515
|
+
`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/http.ts
|
|
519
|
+
function causeText(err) {
|
|
520
|
+
const pick = (e) => {
|
|
521
|
+
if (e instanceof AggregateError) {
|
|
522
|
+
const first = e.errors.find((x) => x instanceof Error && x.message.length > 0);
|
|
523
|
+
if (first) return first.message;
|
|
524
|
+
const code = e.code;
|
|
525
|
+
if (code) return String(code);
|
|
526
|
+
}
|
|
527
|
+
return e.message;
|
|
528
|
+
};
|
|
529
|
+
let text;
|
|
530
|
+
if (err instanceof Error) {
|
|
531
|
+
const fromCause = err.cause instanceof Error ? pick(err.cause) : "";
|
|
532
|
+
text = fromCause || pick(err) || String(err);
|
|
533
|
+
} else {
|
|
534
|
+
text = String(err);
|
|
535
|
+
}
|
|
536
|
+
return sanitizeValue(text);
|
|
537
|
+
}
|
|
538
|
+
function wireText(text) {
|
|
539
|
+
const clean = sanitizeValue(String(text));
|
|
540
|
+
return clean.length > 200 ? `${clean.slice(0, 200)}\u2026` : clean;
|
|
541
|
+
}
|
|
542
|
+
async function safeFetch(url, init, reach, fetchFn = globalThis.fetch) {
|
|
543
|
+
try {
|
|
544
|
+
return await fetchFn(url, init);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
throw new Error(`Can't reach ${reach}. Check your connection (${causeText(err)})`, {
|
|
547
|
+
cause: err
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// src/auth-http.ts
|
|
553
|
+
async function mintYmmvToken(accessToken) {
|
|
554
|
+
const res = await safeFetch(
|
|
555
|
+
`${BASE}/api/v1/auth/token`,
|
|
556
|
+
{
|
|
557
|
+
method: "POST",
|
|
558
|
+
headers: { "content-type": "application/json" },
|
|
559
|
+
body: JSON.stringify({ access_token: accessToken }),
|
|
560
|
+
// Never follow a redirect: a 30x must fail (the existing `!res.ok` guard rejects the resulting
|
|
561
|
+
// opaqueredirect), not re-POST the GitHub access_token to the redirect target or read a
|
|
562
|
+
// redirected 200 as a successful mint. Mirrors publish/delete in api.ts.
|
|
563
|
+
redirect: "manual"
|
|
564
|
+
},
|
|
565
|
+
BASE
|
|
566
|
+
);
|
|
567
|
+
if (!res.ok) {
|
|
568
|
+
const body = await res.json().catch(() => ({}));
|
|
569
|
+
if (res.status === 503) {
|
|
570
|
+
throw new Error(
|
|
571
|
+
body.message ? wireText(body.message) : "GitHub is unavailable. Run `ymmv login` again shortly."
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (res.status === 429) {
|
|
575
|
+
const retry = res.headers.get("retry-after");
|
|
576
|
+
const msg = body.message ? wireText(body.message) : "Too many login attempts. Slow down and try again shortly";
|
|
577
|
+
throw new Error(retry ? `${msg} (retry in ${retry}s)` : msg);
|
|
578
|
+
}
|
|
579
|
+
throw new Error(`login failed: ${res.status} ${wireText(body.error ?? "")}`.trim());
|
|
580
|
+
}
|
|
581
|
+
const data = await res.json().catch(() => null);
|
|
582
|
+
if (!data || typeof data.token !== "string" || data.token.length === 0 || data.handle !== null && typeof data.handle !== "string") {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`Unexpected response from ${BASE}. Nothing was saved; run \`ymmv login\` again.`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
return { token: data.token, handle: data.handle === null ? null : sanitizeValue(data.handle) };
|
|
588
|
+
}
|
|
589
|
+
async function revokeYmmvToken(token) {
|
|
590
|
+
const res = await fetch(`${BASE}/api/v1/auth/logout`, {
|
|
591
|
+
method: "POST",
|
|
592
|
+
headers: { authorization: `Bearer ${token}` },
|
|
593
|
+
// Never follow a redirect: a 30x→200 must not read as a successful revoke (which would delete the
|
|
594
|
+
// local file while the server token stays live). Same guard as mint + publish/delete.
|
|
595
|
+
redirect: "manual"
|
|
596
|
+
});
|
|
597
|
+
if (!res.ok) throw new Error(`logout failed: ${res.status}`);
|
|
598
|
+
const body = await res.json().catch(() => ({}));
|
|
599
|
+
return body.revoked === true;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/commands.ts
|
|
603
|
+
import { readFileSync, statSync } from "fs";
|
|
604
|
+
|
|
295
605
|
// src/token-store.ts
|
|
296
606
|
import { randomUUID } from "crypto";
|
|
297
607
|
import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
@@ -352,15 +662,24 @@ var TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
|
352
662
|
var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
353
663
|
async function requestDeviceCode(deps = {}) {
|
|
354
664
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
355
|
-
const res = await
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
665
|
+
const res = await safeFetch(
|
|
666
|
+
DEVICE_CODE_URL,
|
|
667
|
+
{
|
|
668
|
+
method: "POST",
|
|
669
|
+
headers: { accept: "application/json" },
|
|
670
|
+
body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID })
|
|
671
|
+
},
|
|
672
|
+
"github.com",
|
|
673
|
+
doFetch
|
|
674
|
+
);
|
|
360
675
|
if (!res.ok) {
|
|
361
|
-
throw new Error(`device code request failed: ${res.status} ${await res.text()}`);
|
|
676
|
+
throw new Error(`device code request failed: ${res.status} ${wireText(await res.text())}`);
|
|
677
|
+
}
|
|
678
|
+
const data = await res.json().catch(() => null);
|
|
679
|
+
if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string" || typeof data.expires_in !== "number") {
|
|
680
|
+
throw new Error("GitHub sent an unexpected device-code response. Run `ymmv login` again.");
|
|
362
681
|
}
|
|
363
|
-
return
|
|
682
|
+
return data;
|
|
364
683
|
}
|
|
365
684
|
async function pollForToken(dc, deps = {}) {
|
|
366
685
|
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
@@ -369,30 +688,39 @@ async function pollForToken(dc, deps = {}) {
|
|
|
369
688
|
let interval = dc.interval || 5;
|
|
370
689
|
const deadline = now() + dc.expires_in * 1e3;
|
|
371
690
|
let transientFailures = 0;
|
|
691
|
+
let lastCause = "";
|
|
372
692
|
const MAX_TRANSIENT_FAILURES = 5;
|
|
373
693
|
while (now() < deadline) {
|
|
374
694
|
await sleep(interval * 1e3);
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
695
|
+
let res;
|
|
696
|
+
try {
|
|
697
|
+
res = await doFetch(TOKEN_URL, {
|
|
698
|
+
method: "POST",
|
|
699
|
+
headers: { accept: "application/json" },
|
|
700
|
+
body: new URLSearchParams({
|
|
701
|
+
client_id: GITHUB_CLIENT_ID,
|
|
702
|
+
device_code: dc.device_code,
|
|
703
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
704
|
+
})
|
|
705
|
+
});
|
|
706
|
+
} catch (err) {
|
|
707
|
+
lastCause = causeText(err);
|
|
708
|
+
}
|
|
384
709
|
let tok;
|
|
385
|
-
if (res
|
|
710
|
+
if (res?.ok) {
|
|
386
711
|
try {
|
|
387
712
|
tok = await res.json();
|
|
388
713
|
} catch {
|
|
389
714
|
tok = void 0;
|
|
715
|
+
lastCause = "unexpected response body";
|
|
390
716
|
}
|
|
717
|
+
} else if (res) {
|
|
718
|
+
lastCause = `HTTP ${res.status}`;
|
|
391
719
|
}
|
|
392
720
|
if (tok === void 0) {
|
|
393
721
|
if (++transientFailures >= MAX_TRANSIENT_FAILURES) {
|
|
394
722
|
throw new Error(
|
|
395
|
-
|
|
723
|
+
`GitHub isn't responding to the login poll. Check your connection and run \`ymmv login\` again.${lastCause ? ` (last error: ${lastCause})` : ""}`
|
|
396
724
|
);
|
|
397
725
|
}
|
|
398
726
|
continue;
|
|
@@ -408,22 +736,28 @@ async function pollForToken(dc, deps = {}) {
|
|
|
408
736
|
case "access_denied":
|
|
409
737
|
throw new Error("Authorization denied. Run `ymmv login` to try again.");
|
|
410
738
|
case "expired_token":
|
|
411
|
-
throw new Error("Device code expired
|
|
739
|
+
throw new Error("Device code expired. Run `ymmv login` again.");
|
|
412
740
|
default:
|
|
413
|
-
throw new Error(`device flow failed: ${tok.error ?? "unknown error"}`);
|
|
741
|
+
throw new Error(`device flow failed: ${wireText(tok.error ?? "unknown error")}`);
|
|
414
742
|
}
|
|
415
743
|
}
|
|
416
|
-
throw new Error("Device code expired
|
|
744
|
+
throw new Error("Device code expired. Run `ymmv login` again.");
|
|
417
745
|
}
|
|
418
746
|
async function login(deps = {}) {
|
|
419
747
|
if (!process.stdin.isTTY) {
|
|
420
748
|
throw new Error(
|
|
421
|
-
"Device login needs an interactive terminal
|
|
749
|
+
"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)."
|
|
422
750
|
);
|
|
423
751
|
}
|
|
424
752
|
const dc = await requestDeviceCode(deps);
|
|
425
|
-
|
|
426
|
-
|
|
753
|
+
const color = colorEnabled();
|
|
754
|
+
const c = palette(color);
|
|
755
|
+
const verifyUri = /^https:\/\/github\.com\//.test(dc.verification_uri) ? link(dc.verification_uri, color) : sanitizeValue(dc.verification_uri);
|
|
756
|
+
console.log(
|
|
757
|
+
`
|
|
758
|
+
Open ${verifyUri} and enter code: ${c.bold}${sanitizeValue(dc.user_code)}${c.reset}`
|
|
759
|
+
);
|
|
760
|
+
console.log(` ${c.faint}waiting for GitHub approval\u2026 (Ctrl+C to cancel)${c.reset}
|
|
427
761
|
`);
|
|
428
762
|
const accessToken = await pollForToken(dc, deps);
|
|
429
763
|
const { token, handle } = await mintYmmvToken(accessToken);
|
|
@@ -436,147 +770,17 @@ async function login(deps = {}) {
|
|
|
436
770
|
}
|
|
437
771
|
console.log(
|
|
438
772
|
handle ? ` Logged in as ${handle}.
|
|
439
|
-
` : " Logged in
|
|
440
|
-
);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// src/render.ts
|
|
444
|
-
var ESC = String.fromCharCode(27);
|
|
445
|
-
var CSI = `${ESC}[`;
|
|
446
|
-
var CODES = {
|
|
447
|
-
amber: `${CSI}93m`,
|
|
448
|
-
// DESIGN: amber == ANSI bright-yellow
|
|
449
|
-
faint: `${CSI}90m`,
|
|
450
|
-
bold: `${CSI}1m`,
|
|
451
|
-
reset: `${CSI}0m`
|
|
452
|
-
};
|
|
453
|
-
var NO_CODES = { amber: "", faint: "", bold: "", reset: "" };
|
|
454
|
-
function palette(color) {
|
|
455
|
-
return color ? CODES : NO_CODES;
|
|
456
|
-
}
|
|
457
|
-
var ESC_INTRODUCERS = `${String.fromCharCode(27)}${String.fromCharCode(155)}`;
|
|
458
|
-
var BEL = String.fromCharCode(7);
|
|
459
|
-
var ANSI_RE = new RegExp(
|
|
460
|
-
`[${ESC_INTRODUCERS}][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?${BEL})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`,
|
|
461
|
-
"g"
|
|
462
|
-
);
|
|
463
|
-
var CTRL_RE = new RegExp(
|
|
464
|
-
`[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
|
|
465
|
-
"g"
|
|
466
|
-
);
|
|
467
|
-
var BIDI_RE = new RegExp(
|
|
468
|
-
`[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
|
|
469
|
-
"g"
|
|
470
|
-
);
|
|
471
|
-
function sanitizeValue(value) {
|
|
472
|
-
return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
|
|
473
|
-
}
|
|
474
|
-
function useColor(env, isTTY) {
|
|
475
|
-
if (env.NO_COLOR !== void 0) return false;
|
|
476
|
-
if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
|
|
477
|
-
return isTTY;
|
|
478
|
-
}
|
|
479
|
-
function orderedEntries(profile) {
|
|
480
|
-
const byKey = new Map(
|
|
481
|
-
(profile.entries ?? []).map((e) => [e.key, e.value])
|
|
482
|
-
);
|
|
483
|
-
return CURATED_KEYS.flatMap((key) => {
|
|
484
|
-
const value = byKey.get(key);
|
|
485
|
-
return value === void 0 ? [] : [{ label: KEY_LABELS[key], value: sanitizeValue(value) }];
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
function renderProfile(profile, opts) {
|
|
489
|
-
const c = palette(opts.color);
|
|
490
|
-
const rows = orderedEntries(profile);
|
|
491
|
-
const extras = (profile.extras ?? []).map((x) => ({
|
|
492
|
-
label: sanitizeValue(x.label),
|
|
493
|
-
value: sanitizeValue(x.value)
|
|
494
|
-
}));
|
|
495
|
-
const labelW = Math.max(
|
|
496
|
-
0,
|
|
497
|
-
...rows.map((r) => r.label.length),
|
|
498
|
-
...extras.map((x) => x.label.length)
|
|
773
|
+
` : " Logged in. No handle bound (your GitHub username is a reserved word).\n"
|
|
499
774
|
);
|
|
500
|
-
const lines = ["", ` ${c.bold}${sanitizeValue(profile.handle)}${c.reset}`, ""];
|
|
501
|
-
for (const r of rows) {
|
|
502
|
-
lines.push(` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${r.value}`);
|
|
503
|
-
}
|
|
504
|
-
if (extras.length) {
|
|
505
|
-
lines.push("");
|
|
506
|
-
for (const x of extras) {
|
|
507
|
-
lines.push(` ${c.faint}${x.label.padEnd(labelW)}${c.reset} ${x.value}`);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
lines.push("", ` ${c.faint}updated ${sanitizeValue(profile.updated_at)}${c.reset}`, "");
|
|
511
|
-
return lines.join("\n");
|
|
512
|
-
}
|
|
513
|
-
var MISSING = "\u2014";
|
|
514
|
-
function extrasBlock(extras, theirsLabel, mineLabel, c) {
|
|
515
|
-
if (!extras.theirs.length && !extras.mine.length) return [];
|
|
516
|
-
const out = ["", ` ${c.faint}extras${c.reset}`];
|
|
517
|
-
const line = (who, label, value) => ` ${c.faint}${who}${c.reset} ${sanitizeValue(label)} = ${sanitizeValue(value)}`;
|
|
518
|
-
for (const x of extras.theirs) out.push(line(theirsLabel, x.label, x.value));
|
|
519
|
-
for (const x of extras.mine) out.push(line(mineLabel, x.label, x.value));
|
|
520
|
-
return out;
|
|
521
|
-
}
|
|
522
|
-
function renderDiff(result, opts) {
|
|
523
|
-
const c = palette(opts.color);
|
|
524
|
-
const theirsLabel = sanitizeValue(opts.theirsLabel);
|
|
525
|
-
const mineLabel = sanitizeValue(opts.mineLabel);
|
|
526
|
-
const cells = result.rows.map((r) => ({
|
|
527
|
-
label: r.label,
|
|
528
|
-
theirs: r.theirs === null ? MISSING : sanitizeValue(r.theirs),
|
|
529
|
-
mine: r.mine === null ? MISSING : sanitizeValue(r.mine),
|
|
530
|
-
differ: r.status !== "same"
|
|
531
|
-
}));
|
|
532
|
-
const labelW = Math.max(3, ...cells.map((r) => r.label.length));
|
|
533
|
-
const theirsW = Math.max(theirsLabel.length, ...cells.map((r) => r.theirs.length));
|
|
534
|
-
const lines = [""];
|
|
535
|
-
lines.push(
|
|
536
|
-
` ${c.faint}${"".padEnd(labelW)} ${theirsLabel.padEnd(theirsW)} ${mineLabel}${c.reset}`
|
|
537
|
-
);
|
|
538
|
-
for (const r of cells) {
|
|
539
|
-
if (!opts.color) {
|
|
540
|
-
const sym = r.differ ? "~" : "=";
|
|
541
|
-
lines.push(`${sym} ${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}`);
|
|
542
|
-
} else if (r.differ) {
|
|
543
|
-
lines.push(
|
|
544
|
-
`${c.amber}\u2022${c.reset} ${r.label.padEnd(labelW)} ${c.amber}${r.theirs.padEnd(theirsW)}${c.reset} ${c.amber}${r.mine}${c.reset}`
|
|
545
|
-
);
|
|
546
|
-
} else {
|
|
547
|
-
lines.push(
|
|
548
|
-
` ${c.faint}${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}${c.reset}`
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
lines.push(...extrasBlock(result.extras, theirsLabel, mineLabel, c));
|
|
553
|
-
lines.push(
|
|
554
|
-
"",
|
|
555
|
-
` ${c.faint}${result.differ} differ \xB7 ${result.shared} shared \u2014 your mileage may vary${c.reset}`,
|
|
556
|
-
""
|
|
557
|
-
);
|
|
558
|
-
return lines.join("\n");
|
|
559
|
-
}
|
|
560
|
-
function nudge(color) {
|
|
561
|
-
const c = palette(color);
|
|
562
|
-
return `
|
|
563
|
-
${c.amber}publish yours to diff \u2192${c.reset} run ${c.bold}ymmv${c.reset}
|
|
564
|
-
`;
|
|
565
|
-
}
|
|
566
|
-
function notFound(handle) {
|
|
567
|
-
return `
|
|
568
|
-
no ymmv profile for "${sanitizeValue(handle)}" yet.
|
|
569
|
-
publish one at ymmv.fyi with: npx ymmv-cli
|
|
570
|
-
`;
|
|
571
775
|
}
|
|
572
776
|
|
|
573
777
|
// src/api.ts
|
|
574
778
|
async function rateLimitMessage(res) {
|
|
575
779
|
const retry = res.headers.get("retry-after");
|
|
576
|
-
let msg = "rate limited
|
|
780
|
+
let msg = "rate limited, too many requests";
|
|
577
781
|
try {
|
|
578
782
|
const body = await res.json();
|
|
579
|
-
if (typeof body?.message === "string" && body.message) msg = body.message;
|
|
783
|
+
if (typeof body?.message === "string" && body.message) msg = wireText(body.message);
|
|
580
784
|
} catch {
|
|
581
785
|
}
|
|
582
786
|
return retry ? `${msg} (retry in ${retry}s)` : msg;
|
|
@@ -586,32 +790,43 @@ async function ensureLogin() {
|
|
|
586
790
|
if (existing) return existing;
|
|
587
791
|
await login();
|
|
588
792
|
const fresh = await loadToken();
|
|
589
|
-
if (!fresh) throw new Error("
|
|
793
|
+
if (!fresh) throw new Error("Login did not persist a token. Run `ymmv login`.");
|
|
590
794
|
return fresh;
|
|
591
795
|
}
|
|
592
796
|
async function publishProfile(profile) {
|
|
593
|
-
const send = (c) =>
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
797
|
+
const send = (c) => safeFetch(
|
|
798
|
+
`${BASE}/api/v1/profile`,
|
|
799
|
+
{
|
|
800
|
+
method: "POST",
|
|
801
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${c.token}` },
|
|
802
|
+
// Send the login-bound handle, never a caller-guessed one — the official client never claims
|
|
803
|
+
// a handle it doesn't own.
|
|
804
|
+
body: JSON.stringify({ ...profile, handle: c.handle ?? profile.handle }),
|
|
805
|
+
redirect: "manual"
|
|
806
|
+
// a mutation must never follow a redirect into a false success
|
|
807
|
+
},
|
|
808
|
+
BASE
|
|
809
|
+
);
|
|
602
810
|
let cred = await ensureLogin();
|
|
603
811
|
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
604
812
|
throw new Error(
|
|
605
|
-
"
|
|
813
|
+
"The stored login changed while this command was running. Re-run it under the current account."
|
|
606
814
|
);
|
|
607
815
|
}
|
|
608
816
|
let res = await send(cred);
|
|
609
817
|
if (res.status === 401 || res.status === 409) {
|
|
610
|
-
|
|
818
|
+
const was401 = res.status === 401;
|
|
819
|
+
if (was401) await deleteToken();
|
|
611
820
|
await login();
|
|
612
821
|
cred = await ensureLogin();
|
|
822
|
+
if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
|
|
823
|
+
const bound = sanitizeValue(cred.handle ?? "");
|
|
824
|
+
throw new Error(
|
|
825
|
+
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.`
|
|
826
|
+
);
|
|
827
|
+
}
|
|
613
828
|
res = await send(cred);
|
|
614
|
-
if (res.status === 401) throw new Error("
|
|
829
|
+
if (res.status === 401) throw new Error("Authentication failed. Run `ymmv login`.");
|
|
615
830
|
if (res.status === 409) {
|
|
616
831
|
throw new Error(
|
|
617
832
|
"that handle is taken by another account (your GitHub handle may have been reused)."
|
|
@@ -620,33 +835,37 @@ async function publishProfile(profile) {
|
|
|
620
835
|
}
|
|
621
836
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
622
837
|
if (!res.ok) {
|
|
623
|
-
throw new Error(`publish failed: ${res.status} ${await res.text()}`);
|
|
838
|
+
throw new Error(`publish failed: ${res.status} ${wireText(await res.text())}`);
|
|
624
839
|
}
|
|
625
840
|
const data = await res.json();
|
|
626
841
|
const shown = typeof data.handle === "string" ? sanitizeValue(data.handle) : profile.handle;
|
|
627
|
-
|
|
842
|
+
return { handle: shown, url: `${BASE}/${shown}` };
|
|
628
843
|
}
|
|
629
844
|
async function fetchProfileJson(handle) {
|
|
630
|
-
const res = await
|
|
845
|
+
const res = await safeFetch(`${BASE}/api/v1/u/${encodeURIComponent(handle)}`, void 0, BASE);
|
|
631
846
|
if (res.status === 404) return null;
|
|
632
847
|
if (!res.ok) {
|
|
633
|
-
throw new Error(`fetch failed: ${res.status} ${await res.text()}`);
|
|
848
|
+
throw new Error(`fetch failed: ${res.status} ${wireText(await res.text())}`);
|
|
634
849
|
}
|
|
635
850
|
return parseProfile(await res.json());
|
|
636
851
|
}
|
|
637
852
|
async function deleteProfile() {
|
|
638
853
|
const cred = await ensureLogin();
|
|
639
|
-
const res = await
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
854
|
+
const res = await safeFetch(
|
|
855
|
+
`${BASE}/api/v1/profile`,
|
|
856
|
+
{
|
|
857
|
+
method: "DELETE",
|
|
858
|
+
headers: { authorization: `Bearer ${cred.token}` },
|
|
859
|
+
redirect: "manual"
|
|
860
|
+
},
|
|
861
|
+
BASE
|
|
862
|
+
);
|
|
644
863
|
if (res.status === 401) {
|
|
645
|
-
throw new Error("
|
|
864
|
+
throw new Error("Session expired. Run `ymmv login`, then `ymmv delete` again.");
|
|
646
865
|
}
|
|
647
866
|
if (res.status === 429) throw new Error(await rateLimitMessage(res));
|
|
648
867
|
if (!res.ok) {
|
|
649
|
-
throw new Error(`delete failed: ${res.status} ${await res.text()}`);
|
|
868
|
+
throw new Error(`delete failed: ${res.status} ${wireText(await res.text())}`);
|
|
650
869
|
}
|
|
651
870
|
}
|
|
652
871
|
|
|
@@ -661,7 +880,7 @@ function firstToken(s) {
|
|
|
661
880
|
return s.trim().split(/\s+/)[0] ?? "";
|
|
662
881
|
}
|
|
663
882
|
var namesFor = (key) => {
|
|
664
|
-
const map =
|
|
883
|
+
const map = /* @__PURE__ */ Object.create(null);
|
|
665
884
|
for (const tool of TOOLS) {
|
|
666
885
|
if (tool.key !== key) continue;
|
|
667
886
|
for (const token of tool.envTokens ?? []) map[token] = tool.canonical;
|
|
@@ -671,15 +890,44 @@ var namesFor = (key) => {
|
|
|
671
890
|
var SHELL_NAMES = namesFor("shell");
|
|
672
891
|
var EDITOR_NAMES = namesFor("editor");
|
|
673
892
|
var TERM_PROGRAMS = namesFor("terminal");
|
|
674
|
-
|
|
675
|
-
|
|
893
|
+
var BROWSER_NAMES = namesFor("browser");
|
|
894
|
+
var WM_NAMES = namesFor("window-manager");
|
|
895
|
+
var TERM_EXACT = Object.assign(/* @__PURE__ */ Object.create(null), {
|
|
896
|
+
"xterm-kitty": "kitty",
|
|
897
|
+
"xterm-ghostty": "Ghostty",
|
|
898
|
+
foot: "foot",
|
|
899
|
+
alacritty: "Alacritty"
|
|
900
|
+
});
|
|
901
|
+
var BROWSER_DISPATCHERS = /* @__PURE__ */ new Set([
|
|
902
|
+
"xdg-open",
|
|
903
|
+
"wslview",
|
|
904
|
+
"sensible-browser",
|
|
905
|
+
"x-www-browser",
|
|
906
|
+
"www-browser",
|
|
907
|
+
"open",
|
|
908
|
+
"gio",
|
|
909
|
+
"browser.sh",
|
|
910
|
+
"browser.cmd"
|
|
911
|
+
]);
|
|
912
|
+
function linuxDistro(read) {
|
|
913
|
+
if (!read) return void 0;
|
|
914
|
+
try {
|
|
915
|
+
const line = read("/etc/os-release").split("\n").find((l) => l.startsWith("NAME="));
|
|
916
|
+
const name = line?.slice("NAME=".length).trim().replace(/^["']|["']$/g, "");
|
|
917
|
+
return name?.trim() || void 0;
|
|
918
|
+
} catch {
|
|
919
|
+
return void 0;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
function detectOS(env, platform, opts) {
|
|
923
|
+
if (platform === "linux" && env.WSL_DISTRO_NAME) return `${env.WSL_DISTRO_NAME} (WSL)`;
|
|
676
924
|
switch (platform) {
|
|
677
925
|
case "darwin":
|
|
678
926
|
return "macOS";
|
|
679
927
|
case "win32":
|
|
680
928
|
return "Windows";
|
|
681
929
|
case "linux":
|
|
682
|
-
return "Linux";
|
|
930
|
+
return linuxDistro(opts.readTextFile) ?? "Linux";
|
|
683
931
|
default:
|
|
684
932
|
return platform;
|
|
685
933
|
}
|
|
@@ -691,6 +939,9 @@ function detectShell(env, platform) {
|
|
|
691
939
|
return SHELL_NAMES[key] ?? basename(raw);
|
|
692
940
|
}
|
|
693
941
|
if (platform === "win32") {
|
|
942
|
+
const firstSeg = env.PSModulePath?.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
943
|
+
if (/[\\/]documents[\\/](windows)?powershell[\\/]modules$/.test(firstSeg)) return "PowerShell";
|
|
944
|
+
if (env.PROMPT) return env.ComSpec ? basename(env.ComSpec) : "cmd";
|
|
694
945
|
if (env.PSModulePath) return "PowerShell";
|
|
695
946
|
if (env.ComSpec) return basename(env.ComSpec);
|
|
696
947
|
}
|
|
@@ -699,18 +950,37 @@ function detectShell(env, platform) {
|
|
|
699
950
|
function detectTerminal(env) {
|
|
700
951
|
const tp = env.TERM_PROGRAM?.trim();
|
|
701
952
|
if (tp && tp.toLowerCase() !== "tmux") {
|
|
953
|
+
if (tp.toLowerCase() === "vscode" && env.CURSOR_TRACE_ID) return "Cursor";
|
|
702
954
|
return TERM_PROGRAMS[tp.toLowerCase()] ?? tp;
|
|
703
955
|
}
|
|
956
|
+
if (env.TERMINAL_EMULATOR === "JetBrains-JediTerm") return "JetBrains";
|
|
704
957
|
if (env.WT_SESSION) return "Windows Terminal";
|
|
705
958
|
if (env.KONSOLE_VERSION) return "Konsole";
|
|
706
959
|
if (env.ALACRITTY_WINDOW_ID || env.ALACRITTY_SOCKET) return "Alacritty";
|
|
960
|
+
if (env.KITTY_WINDOW_ID || env.KITTY_PID) return "kitty";
|
|
961
|
+
if (env.GNOME_TERMINAL_SERVICE || env.GNOME_TERMINAL_SCREEN) return "GNOME Terminal";
|
|
962
|
+
if (env.TILIX_ID) return "Tilix";
|
|
963
|
+
if (env.TERMINATOR_UUID) return "Terminator";
|
|
964
|
+
if (env.ConEmuANSI || env.ConEmuPID) return "ConEmu";
|
|
965
|
+
if (env.XTERM_VERSION) return "xterm";
|
|
966
|
+
const t = env.TERM?.trim().toLowerCase();
|
|
967
|
+
if (t && TERM_EXACT[t]) return TERM_EXACT[t];
|
|
707
968
|
return void 0;
|
|
708
969
|
}
|
|
709
970
|
function detectEditor(env) {
|
|
710
971
|
const raw = env.VISUAL ?? env.EDITOR;
|
|
711
|
-
if (
|
|
712
|
-
|
|
713
|
-
|
|
972
|
+
if (raw) {
|
|
973
|
+
const bin = basename(firstToken(raw));
|
|
974
|
+
return EDITOR_NAMES[bin.toLowerCase()] ?? bin;
|
|
975
|
+
}
|
|
976
|
+
if (env.NVIM) return "Neovim";
|
|
977
|
+
if (env.VIM_TERMINAL) return "Vim";
|
|
978
|
+
if (env.INSIDE_EMACS) return "Emacs";
|
|
979
|
+
if (env.CURSOR_TRACE_ID) return "Cursor";
|
|
980
|
+
const tp = env.TERM_PROGRAM?.trim().toLowerCase();
|
|
981
|
+
if (tp === "vscode") return "VS Code";
|
|
982
|
+
if (tp === "zed" || env.ZED_TERM === "true") return "Zed";
|
|
983
|
+
return void 0;
|
|
714
984
|
}
|
|
715
985
|
function detectMultiplexer(env) {
|
|
716
986
|
if (env.TMUX) return "tmux";
|
|
@@ -718,17 +988,92 @@ function detectMultiplexer(env) {
|
|
|
718
988
|
if (env.STY) return "GNU Screen";
|
|
719
989
|
return void 0;
|
|
720
990
|
}
|
|
721
|
-
function
|
|
991
|
+
function detectPrompt(env) {
|
|
992
|
+
if (env.STARSHIP_SHELL || env.STARSHIP_SESSION_KEY) return "Starship";
|
|
993
|
+
if (env.POSH_SHELL || env.POSH_SESSION_ID || env.POSH_THEME) return "Oh My Posh";
|
|
994
|
+
if (env.P9K_TTY || env._P9K_TTY) return "Powerlevel10k";
|
|
995
|
+
if (env.SPACESHIP_VERSION) return "Spaceship";
|
|
996
|
+
if (Object.keys(env).some((k) => k.startsWith("_tide_"))) return "Tide";
|
|
997
|
+
return void 0;
|
|
998
|
+
}
|
|
999
|
+
function detectVersionManager(env) {
|
|
1000
|
+
const path = env.PATH ?? "";
|
|
1001
|
+
if (env.MISE_SHELL || env.__MISE_ORIG_PATH || /[\\/]mise[\\/]shims/i.test(path)) return "mise";
|
|
1002
|
+
if (env.ASDF_DIR || env.ASDF_DATA_DIR || /\.asdf[\\/]shims/i.test(path)) return "asdf";
|
|
1003
|
+
if (env.PROTO_HOME) return "proto";
|
|
1004
|
+
if (env.VOLTA_HOME) return "Volta";
|
|
1005
|
+
if (env.FNM_MULTISHELL_PATH || env.FNM_DIR) return "fnm";
|
|
1006
|
+
if (env.NVM_DIR) return "nvm";
|
|
1007
|
+
if (env.PYENV_SHELL || env.PYENV_ROOT) return "pyenv";
|
|
1008
|
+
if (env.RBENV_SHELL || env.RBENV_ROOT) return "rbenv";
|
|
1009
|
+
return void 0;
|
|
1010
|
+
}
|
|
1011
|
+
function detectWindowManager(env) {
|
|
1012
|
+
if (env.HYPRLAND_INSTANCE_SIGNATURE) return "Hyprland";
|
|
1013
|
+
if (env.NIRI_SOCKET) return "niri";
|
|
1014
|
+
if (env.SWAYSOCK) return "Sway";
|
|
1015
|
+
if (env.I3SOCK) return "i3";
|
|
1016
|
+
for (const part of env.XDG_CURRENT_DESKTOP?.split(":") ?? []) {
|
|
1017
|
+
const hit = WM_NAMES[part.trim().toLowerCase()];
|
|
1018
|
+
if (hit) return hit;
|
|
1019
|
+
}
|
|
1020
|
+
const ds = env.DESKTOP_SESSION;
|
|
1021
|
+
if (ds) {
|
|
1022
|
+
const hit = WM_NAMES[basename(ds).toLowerCase()];
|
|
1023
|
+
if (hit) return hit;
|
|
1024
|
+
}
|
|
1025
|
+
if (env.KDE_FULL_SESSION || env.KDE_SESSION_VERSION) return "KDE Plasma";
|
|
1026
|
+
return void 0;
|
|
1027
|
+
}
|
|
1028
|
+
function detectBrowser(env) {
|
|
1029
|
+
const raw = env.BROWSER;
|
|
1030
|
+
if (!raw) return void 0;
|
|
1031
|
+
const trimmed = raw.trim();
|
|
1032
|
+
if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.includes("\\")) {
|
|
1033
|
+
const bin = basename(trimmed);
|
|
1034
|
+
if (!bin || BROWSER_DISPATCHERS.has(bin.toLowerCase())) return void 0;
|
|
1035
|
+
return BROWSER_NAMES[bin.toLowerCase()] ?? bin;
|
|
1036
|
+
}
|
|
1037
|
+
for (const candidate of trimmed.split(":")) {
|
|
1038
|
+
const bin = basename(firstToken(candidate));
|
|
1039
|
+
if (!bin) continue;
|
|
1040
|
+
if (BROWSER_DISPATCHERS.has(bin.toLowerCase())) continue;
|
|
1041
|
+
const hit = BROWSER_NAMES[bin.toLowerCase()];
|
|
1042
|
+
if (hit) return hit;
|
|
1043
|
+
if (candidate.startsWith("/") && /\s/.test(candidate.trim())) {
|
|
1044
|
+
const whole = basename(candidate.trim().replace(/\s+%[a-zA-Z].*$/, ""));
|
|
1045
|
+
if (whole && !whole.includes("/")) return BROWSER_NAMES[whole.toLowerCase()] ?? whole;
|
|
1046
|
+
}
|
|
1047
|
+
return bin;
|
|
1048
|
+
}
|
|
1049
|
+
return void 0;
|
|
1050
|
+
}
|
|
1051
|
+
function detectAiTool(env) {
|
|
1052
|
+
if (env.AGENT?.trim().toLowerCase() === "amp") return "Amp";
|
|
1053
|
+
if (env.CLAUDECODE) return "Claude Code";
|
|
1054
|
+
if (env.CODEX_THREAD_ID) return "Codex";
|
|
1055
|
+
if (env.GEMINI_CLI) return "Gemini CLI";
|
|
1056
|
+
if (env.OPENCODE) return "opencode";
|
|
1057
|
+
if (env.COPILOT_CLI) return "GitHub Copilot";
|
|
1058
|
+
if (env.CURSOR_TRACE_ID || env.CURSOR_AGENT) return "Cursor";
|
|
1059
|
+
return void 0;
|
|
1060
|
+
}
|
|
1061
|
+
function detectStack(env, platform, opts = {}) {
|
|
722
1062
|
const out = /* @__PURE__ */ new Map();
|
|
723
1063
|
const set = (key, value) => {
|
|
724
1064
|
if (value?.trim()) out.set(key, value.trim());
|
|
725
1065
|
};
|
|
726
1066
|
try {
|
|
727
|
-
set("os", detectOS(env, platform));
|
|
1067
|
+
set("os", detectOS(env, platform, opts));
|
|
728
1068
|
set("shell", detectShell(env, platform));
|
|
1069
|
+
set("prompt", detectPrompt(env));
|
|
729
1070
|
set("terminal", detectTerminal(env));
|
|
730
1071
|
set("editor", detectEditor(env));
|
|
731
1072
|
set("multiplexer", detectMultiplexer(env));
|
|
1073
|
+
set("version-manager", detectVersionManager(env));
|
|
1074
|
+
set("window-manager", detectWindowManager(env));
|
|
1075
|
+
set("browser", detectBrowser(env));
|
|
1076
|
+
set("ai-tool", detectAiTool(env));
|
|
732
1077
|
} catch {
|
|
733
1078
|
}
|
|
734
1079
|
return out;
|
|
@@ -752,6 +1097,9 @@ function entriesFromMap(map) {
|
|
|
752
1097
|
return value ? [{ key, value }] : [];
|
|
753
1098
|
});
|
|
754
1099
|
}
|
|
1100
|
+
function unknownEntries(existing) {
|
|
1101
|
+
return (existing?.entries ?? []).filter((e) => !isCuratedKey(e.key));
|
|
1102
|
+
}
|
|
755
1103
|
function applySet(existing, target) {
|
|
756
1104
|
const entries = (existing?.entries ?? []).map((e) => ({ ...e }));
|
|
757
1105
|
const extras = (existing?.extras ?? []).map((x) => ({ ...x }));
|
|
@@ -791,10 +1139,87 @@ function applyUnset(existing, target) {
|
|
|
791
1139
|
};
|
|
792
1140
|
}
|
|
793
1141
|
|
|
794
|
-
// src/
|
|
795
|
-
|
|
796
|
-
|
|
1142
|
+
// src/prompt.ts
|
|
1143
|
+
import { stdin, stdout } from "process";
|
|
1144
|
+
import { createInterface } from "readline/promises";
|
|
1145
|
+
var PromptAborted = class extends Error {
|
|
1146
|
+
constructor() {
|
|
1147
|
+
super("aborted");
|
|
1148
|
+
this.name = "PromptAborted";
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
function promptLine(label, def, color = false) {
|
|
1152
|
+
const c = palette(color);
|
|
1153
|
+
const clean = def ? sanitizeValue(def) : def;
|
|
1154
|
+
return ` ${c.faint}${label}${c.reset}${clean ? ` [${clean}]` : ""}: `;
|
|
1155
|
+
}
|
|
1156
|
+
function matchChoice(answer, keys, def) {
|
|
1157
|
+
if (keys.some((k) => k.length !== 1) || new Set(keys).size !== keys.length) {
|
|
1158
|
+
throw new Error(`choice keys must be unique single letters: ${keys.join(",")}`);
|
|
1159
|
+
}
|
|
1160
|
+
const a = answer.trim().toLowerCase();
|
|
1161
|
+
if (a === "") return def;
|
|
1162
|
+
const first = a[0];
|
|
1163
|
+
return keys.includes(first) ? first : null;
|
|
797
1164
|
}
|
|
1165
|
+
function makePrompter() {
|
|
1166
|
+
let rl = null;
|
|
1167
|
+
const color = colorEnabled();
|
|
1168
|
+
const c = palette(color);
|
|
1169
|
+
let ac = null;
|
|
1170
|
+
const io = () => {
|
|
1171
|
+
if (!rl) {
|
|
1172
|
+
rl = createInterface({ input: stdin, output: stdout });
|
|
1173
|
+
rl.on("SIGINT", () => {
|
|
1174
|
+
if (ac) ac.abort();
|
|
1175
|
+
else {
|
|
1176
|
+
stdout.write("\n");
|
|
1177
|
+
process.exit(130);
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
rl.on("close", () => {
|
|
1181
|
+
ac?.abort();
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
return rl;
|
|
1185
|
+
};
|
|
1186
|
+
const question = async (query) => {
|
|
1187
|
+
const controller = new AbortController();
|
|
1188
|
+
ac = controller;
|
|
1189
|
+
try {
|
|
1190
|
+
return await io().question(query, { signal: controller.signal });
|
|
1191
|
+
} catch (e) {
|
|
1192
|
+
if (e instanceof Error && e.name === "AbortError") throw new PromptAborted();
|
|
1193
|
+
throw e;
|
|
1194
|
+
} finally {
|
|
1195
|
+
ac = null;
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
return {
|
|
1199
|
+
async ask(label, def) {
|
|
1200
|
+
const clean = def ? sanitizeValue(def) : def;
|
|
1201
|
+
const answer = (await question(promptLine(label, def, color))).trim();
|
|
1202
|
+
return answer === "" ? clean ?? "" : answer;
|
|
1203
|
+
},
|
|
1204
|
+
async confirm(q, defYes) {
|
|
1205
|
+
const answer = (await question(` ${q} ${c.faint}[${defYes ? "Y/n" : "y/N"}]${c.reset} `)).trim().toLowerCase();
|
|
1206
|
+
if (answer === "") return defYes;
|
|
1207
|
+
return answer === "y" || answer === "yes";
|
|
1208
|
+
},
|
|
1209
|
+
async choice(q, keys, def, hint) {
|
|
1210
|
+
for (; ; ) {
|
|
1211
|
+
const hit = matchChoice(await question(` ${q} ${c.faint}[${hint}]${c.reset} `), keys, def);
|
|
1212
|
+
if (hit !== null) return hit;
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
close() {
|
|
1216
|
+
rl?.close();
|
|
1217
|
+
rl = null;
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/commands.ts
|
|
798
1223
|
function requireHandle(cred) {
|
|
799
1224
|
if (cred.handle) return cred.handle;
|
|
800
1225
|
console.error(
|
|
@@ -806,7 +1231,7 @@ function requireHandle(cred) {
|
|
|
806
1231
|
function assertHandleUnchanged(existing, handle) {
|
|
807
1232
|
if (existing && existing.handle.toLowerCase() !== handle.toLowerCase()) {
|
|
808
1233
|
throw new Error(
|
|
809
|
-
`
|
|
1234
|
+
`This login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}". Run \`ymmv login\` to refresh, then retry.`
|
|
810
1235
|
);
|
|
811
1236
|
}
|
|
812
1237
|
}
|
|
@@ -819,44 +1244,113 @@ function newProfile(handle, entries, extras) {
|
|
|
819
1244
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
820
1245
|
};
|
|
821
1246
|
}
|
|
1247
|
+
function printPublished(res, color) {
|
|
1248
|
+
console.log(`Published ${res.handle} \u2192 ${link(res.url, color)}`);
|
|
1249
|
+
}
|
|
1250
|
+
function pagePointer(handle) {
|
|
1251
|
+
const color = colorEnabled();
|
|
1252
|
+
const c = palette(color);
|
|
1253
|
+
return ` ${c.faint}\u2192 ${color ? displayUrl(BASE) : BASE}/${handle}${c.reset}`;
|
|
1254
|
+
}
|
|
822
1255
|
async function promptEntries(defaults, prompter) {
|
|
1256
|
+
const c = palette(colorEnabled());
|
|
1257
|
+
console.log(`
|
|
1258
|
+
${c.faint}Enter to keep, "-" to clear${c.reset}`);
|
|
823
1259
|
const chosen = /* @__PURE__ */ new Map();
|
|
824
1260
|
for (const key of CURATED_KEYS) {
|
|
825
1261
|
const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
|
|
826
1262
|
const value = answer === "-" ? "" : answer;
|
|
827
1263
|
if (value) chosen.set(key, value);
|
|
828
1264
|
}
|
|
829
|
-
return
|
|
1265
|
+
return chosen;
|
|
830
1266
|
}
|
|
831
1267
|
async function publish(io) {
|
|
1268
|
+
if (!io.interactive && !io.yes) {
|
|
1269
|
+
console.error("Non-interactive publish needs -y (nothing publishes unconfirmed): ymmv -y");
|
|
1270
|
+
process.exitCode = 1;
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
832
1273
|
const cred = await ensureLogin();
|
|
833
1274
|
const handle = requireHandle(cred);
|
|
834
1275
|
if (!handle) return;
|
|
835
|
-
const detected = detectStack(process.env, process.platform
|
|
1276
|
+
const detected = detectStack(process.env, process.platform, {
|
|
1277
|
+
readTextFile: (p) => {
|
|
1278
|
+
const st = statSync(p);
|
|
1279
|
+
if (!st.isFile() || st.size > 64 * 1024) throw new Error("not a readable os-release");
|
|
1280
|
+
return readFileSync(p, "utf8");
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
836
1283
|
const existing = await fetchProfileJson(handle);
|
|
837
1284
|
assertHandleUnchanged(existing, handle);
|
|
838
1285
|
const defaults = buildDefaults(existing, detected);
|
|
839
|
-
|
|
1286
|
+
const carried = unknownEntries(existing);
|
|
840
1287
|
const extras = existing?.extras ?? [];
|
|
841
|
-
|
|
842
|
-
|
|
1288
|
+
const color = colorEnabled();
|
|
1289
|
+
const site = displayUrl(BASE);
|
|
1290
|
+
const showCard = (entries) => {
|
|
1291
|
+
console.log(
|
|
1292
|
+
renderProfile(newProfile(handle, entries, extras), { color, site, mode: "preview" })
|
|
1293
|
+
);
|
|
1294
|
+
if (carried.length > 0) {
|
|
1295
|
+
const s = carried.length === 1 ? "" : "s";
|
|
1296
|
+
console.log(`(+${carried.length} newer field${s} kept as-is; upgrade ymmv-cli to edit them)`);
|
|
1297
|
+
for (const e of carried) {
|
|
1298
|
+
console.log(` ${sanitizeValue(e.key)} = ${sanitizeValue(e.value)}`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
const publishedLabels = new Set(
|
|
1302
|
+
entries.filter((e) => isCuratedKey(e.key)).map((e) => KEY_LABELS[e.key].toLowerCase())
|
|
1303
|
+
);
|
|
1304
|
+
for (const x of extras) {
|
|
1305
|
+
if (publishedLabels.has(x.label.trim().toLowerCase())) {
|
|
1306
|
+
const label = sanitizeValue(x.label.trim());
|
|
1307
|
+
console.log(`(extra "${label}" duplicates a curated field; ymmv unset --extra "${label}")`);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
let values = defaults;
|
|
1312
|
+
const assemble = () => [...entriesFromMap(values), ...carried];
|
|
1313
|
+
if (!io.interactive || !io.prompter || io.yes) {
|
|
1314
|
+
const entries = assemble();
|
|
1315
|
+
showCard(entries);
|
|
1316
|
+
printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
|
|
1317
|
+
return;
|
|
843
1318
|
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1319
|
+
try {
|
|
1320
|
+
if (!existing) values = await promptEntries(values, io.prompter);
|
|
1321
|
+
for (; ; ) {
|
|
1322
|
+
const entries = assemble();
|
|
1323
|
+
showCard(entries);
|
|
1324
|
+
const ans = await io.prompter.choice(
|
|
1325
|
+
`Publish to ${site}/${sanitizeValue(handle)}?`,
|
|
1326
|
+
["y", "n", "e"],
|
|
1327
|
+
"y",
|
|
1328
|
+
"Y/n/e=edit"
|
|
1329
|
+
);
|
|
1330
|
+
if (ans === "y") {
|
|
1331
|
+
printPublished(await publishProfile(newProfile(handle, entries, extras)), color);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
if (ans === "n") {
|
|
1335
|
+
console.log("Aborted. Nothing published.");
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
values = await promptEntries(values, io.prompter);
|
|
1339
|
+
}
|
|
1340
|
+
} catch (e) {
|
|
1341
|
+
if (e instanceof PromptAborted) {
|
|
1342
|
+
console.log("\nAborted. Nothing published.");
|
|
1343
|
+
process.exitCode = 130;
|
|
850
1344
|
return;
|
|
851
1345
|
}
|
|
1346
|
+
throw e;
|
|
852
1347
|
}
|
|
853
|
-
await publishProfile(profile);
|
|
854
1348
|
}
|
|
855
1349
|
async function view(handle) {
|
|
856
1350
|
const theirs = await fetchProfileJson(handle);
|
|
857
1351
|
const c = colorEnabled();
|
|
858
1352
|
if (!theirs) {
|
|
859
|
-
console.log(notFound(handle));
|
|
1353
|
+
console.log(notFound(handle, c, BASE));
|
|
860
1354
|
return;
|
|
861
1355
|
}
|
|
862
1356
|
const cred = await loadToken();
|
|
@@ -869,12 +1363,12 @@ async function view(handle) {
|
|
|
869
1363
|
return;
|
|
870
1364
|
}
|
|
871
1365
|
if (!mine) {
|
|
872
|
-
console.log(renderProfile(theirs, { color: c }));
|
|
1366
|
+
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
873
1367
|
console.log(nudge(c));
|
|
874
1368
|
return;
|
|
875
1369
|
}
|
|
876
1370
|
}
|
|
877
|
-
console.log(renderProfile(theirs, { color: c }));
|
|
1371
|
+
console.log(renderProfile(theirs, { color: c, site: displayUrl(BASE) }));
|
|
878
1372
|
}
|
|
879
1373
|
async function runSet(target) {
|
|
880
1374
|
const cred = await ensureLogin();
|
|
@@ -883,12 +1377,9 @@ async function runSet(target) {
|
|
|
883
1377
|
const existing = await fetchProfileJson(handle);
|
|
884
1378
|
assertHandleUnchanged(existing, handle);
|
|
885
1379
|
const { entries, extras } = applySet(existing, target);
|
|
886
|
-
await publishProfile(newProfile(handle, entries, extras));
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
} else {
|
|
890
|
-
console.log(`Set extra ${target.label} = ${target.value}.`);
|
|
891
|
-
}
|
|
1380
|
+
const res = await publishProfile(newProfile(handle, entries, extras));
|
|
1381
|
+
const line = target.kind === "curated" ? `Set ${KEY_LABELS[target.key]} = ${target.value}.` : `Set extra ${target.label} = ${target.value}.`;
|
|
1382
|
+
console.log(`${line}${pagePointer(res.handle)}`);
|
|
892
1383
|
}
|
|
893
1384
|
async function runUnset(target) {
|
|
894
1385
|
const cred = await ensureLogin();
|
|
@@ -897,7 +1388,7 @@ async function runUnset(target) {
|
|
|
897
1388
|
const existing = await fetchProfileJson(handle);
|
|
898
1389
|
assertHandleUnchanged(existing, handle);
|
|
899
1390
|
if (!existing) {
|
|
900
|
-
console.log("No profile yet
|
|
1391
|
+
console.log("No profile yet. Run `ymmv` to publish one.");
|
|
901
1392
|
return;
|
|
902
1393
|
}
|
|
903
1394
|
const { entries, extras, removed } = applyUnset(existing, target);
|
|
@@ -907,18 +1398,13 @@ async function runUnset(target) {
|
|
|
907
1398
|
);
|
|
908
1399
|
return;
|
|
909
1400
|
}
|
|
910
|
-
await publishProfile(newProfile(handle, entries, extras));
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
} else {
|
|
914
|
-
console.log(
|
|
915
|
-
`Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`
|
|
916
|
-
);
|
|
917
|
-
}
|
|
1401
|
+
const res = await publishProfile(newProfile(handle, entries, extras));
|
|
1402
|
+
const line = target.kind === "curated" ? `Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").` : `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`;
|
|
1403
|
+
console.log(`${line}${pagePointer(res.handle)}`);
|
|
918
1404
|
}
|
|
919
1405
|
async function runDelete(io) {
|
|
920
1406
|
const cred = await ensureLogin();
|
|
921
|
-
const target = cred.handle ?
|
|
1407
|
+
const target = cred.handle ? `${displayUrl(BASE)}/${sanitizeValue(cred.handle)}` : "your profile";
|
|
922
1408
|
if (!io.yes) {
|
|
923
1409
|
if (!io.interactive || !io.prompter) {
|
|
924
1410
|
console.error(
|
|
@@ -927,9 +1413,19 @@ async function runDelete(io) {
|
|
|
927
1413
|
process.exitCode = 1;
|
|
928
1414
|
return;
|
|
929
1415
|
}
|
|
930
|
-
|
|
1416
|
+
let go;
|
|
1417
|
+
try {
|
|
1418
|
+
go = await io.prompter.confirm(`Delete ${target}? This is permanent`, false);
|
|
1419
|
+
} catch (e) {
|
|
1420
|
+
if (e instanceof PromptAborted) {
|
|
1421
|
+
console.log("\nCancelled. Nothing deleted.");
|
|
1422
|
+
process.exitCode = 130;
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
throw e;
|
|
1426
|
+
}
|
|
931
1427
|
if (!go) {
|
|
932
|
-
console.log("Cancelled
|
|
1428
|
+
console.log("Cancelled. Nothing deleted.");
|
|
933
1429
|
return;
|
|
934
1430
|
}
|
|
935
1431
|
}
|
|
@@ -938,32 +1434,6 @@ async function runDelete(io) {
|
|
|
938
1434
|
console.log(`Deleted ${target}. Run \`ymmv\` to publish again.`);
|
|
939
1435
|
}
|
|
940
1436
|
|
|
941
|
-
// src/prompt.ts
|
|
942
|
-
import { stdin, stdout } from "process";
|
|
943
|
-
import { createInterface } from "readline/promises";
|
|
944
|
-
function makePrompter() {
|
|
945
|
-
let rl = null;
|
|
946
|
-
const io = () => {
|
|
947
|
-
rl ??= createInterface({ input: stdin, output: stdout });
|
|
948
|
-
return rl;
|
|
949
|
-
};
|
|
950
|
-
return {
|
|
951
|
-
async ask(label, def) {
|
|
952
|
-
const answer = (await io().question(` ${label}${def ? ` [${def}]` : ""}: `)).trim();
|
|
953
|
-
return answer === "" ? def ?? "" : answer;
|
|
954
|
-
},
|
|
955
|
-
async confirm(question, defYes) {
|
|
956
|
-
const answer = (await io().question(` ${question} ${defYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
957
|
-
if (answer === "") return defYes;
|
|
958
|
-
return answer === "y" || answer === "yes";
|
|
959
|
-
},
|
|
960
|
-
close() {
|
|
961
|
-
rl?.close();
|
|
962
|
-
rl = null;
|
|
963
|
-
}
|
|
964
|
-
};
|
|
965
|
-
}
|
|
966
|
-
|
|
967
1437
|
// src/resolve.ts
|
|
968
1438
|
var SET_EXTRA = 'ymmv set --extra "Label=Value"';
|
|
969
1439
|
var UNSET_EXTRA = 'ymmv unset --extra "Label"';
|
|
@@ -1037,7 +1507,7 @@ function resolveArg(argv) {
|
|
|
1037
1507
|
return { kind: "view", handle };
|
|
1038
1508
|
}
|
|
1039
1509
|
if (first.startsWith("-")) {
|
|
1040
|
-
return { kind: "error", message: `
|
|
1510
|
+
return { kind: "error", message: `Unknown option "${first}". Run \`ymmv help\`.` };
|
|
1041
1511
|
}
|
|
1042
1512
|
if (!isValidHandle(first)) {
|
|
1043
1513
|
return {
|
|
@@ -1049,11 +1519,12 @@ function resolveArg(argv) {
|
|
|
1049
1519
|
}
|
|
1050
1520
|
|
|
1051
1521
|
// src/index.ts
|
|
1052
|
-
var
|
|
1522
|
+
var help = (c) => `${c.bold}ymmv${c.reset}: terminal-native developer tool-stack profiles (ymmv.fyi)
|
|
1053
1523
|
|
|
1054
|
-
Usage
|
|
1524
|
+
${c.faint}Usage:${c.reset}
|
|
1055
1525
|
ymmv detect your stack, confirm, and publish your profile
|
|
1056
|
-
ymmv
|
|
1526
|
+
ymmv -y publish without prompts (required when stdin isn't a TTY)
|
|
1527
|
+
ymmv <handle> view a profile; logged in, see the diff vs yours
|
|
1057
1528
|
ymmv view <handle> explicit view (when a handle collides with a verb)
|
|
1058
1529
|
ymmv set <key> <value> set one curated key
|
|
1059
1530
|
ymmv set --extra "L=V" set a free-form extra
|
|
@@ -1063,17 +1534,14 @@ Usage:
|
|
|
1063
1534
|
ymmv login | logout GitHub device-flow auth
|
|
1064
1535
|
ymmv help | --version
|
|
1065
1536
|
|
|
1066
|
-
Curated keys
|
|
1067
|
-
multiplexer, dotfiles, ai-tool
|
|
1068
|
-
|
|
1069
|
-
Respects NO_COLOR. Point YMMV_API at a dev Worker to target one.
|
|
1070
|
-
Publish your own: npx ymmv-cli`;
|
|
1537
|
+
${c.faint}Curated keys:${c.reset} editor, os, shell, prompt, terminal, browser, window-manager,
|
|
1538
|
+
font, theme, multiplexer, version-manager, dotfiles, ai-tool`;
|
|
1071
1539
|
async function logout() {
|
|
1072
1540
|
const stored = await loadToken();
|
|
1073
1541
|
if (!stored) {
|
|
1074
1542
|
const otherBase = await peekBase();
|
|
1075
1543
|
console.log(
|
|
1076
|
-
otherBase && otherBase !== BASE ? `Not logged in to ${BASE} (a token for ${otherBase} exists
|
|
1544
|
+
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."
|
|
1077
1545
|
);
|
|
1078
1546
|
return;
|
|
1079
1547
|
}
|
|
@@ -1082,7 +1550,7 @@ async function logout() {
|
|
|
1082
1550
|
revoked = await revokeYmmvToken(stored.token);
|
|
1083
1551
|
} catch {
|
|
1084
1552
|
console.error(
|
|
1085
|
-
"Couldn't reach the server to revoke
|
|
1553
|
+
"Couldn't reach the server to revoke. Your token is still active. Run `ymmv logout` again when connected."
|
|
1086
1554
|
);
|
|
1087
1555
|
process.exitCode = 1;
|
|
1088
1556
|
return;
|
|
@@ -1092,7 +1560,7 @@ async function logout() {
|
|
|
1092
1560
|
}
|
|
1093
1561
|
function printVersion() {
|
|
1094
1562
|
try {
|
|
1095
|
-
const pkg = JSON.parse(
|
|
1563
|
+
const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
|
|
1096
1564
|
console.log(`ymmv-cli ${pkg.version ?? "unknown"}`);
|
|
1097
1565
|
} catch {
|
|
1098
1566
|
console.log("ymmv-cli (version unknown)");
|
|
@@ -1125,14 +1593,17 @@ async function main(argv) {
|
|
|
1125
1593
|
case "delete":
|
|
1126
1594
|
await interactive(runDelete, cmd.yes);
|
|
1127
1595
|
break;
|
|
1128
|
-
case "login":
|
|
1596
|
+
case "login": {
|
|
1129
1597
|
await login();
|
|
1598
|
+
const c = palette(colorEnabled());
|
|
1599
|
+
console.log(` ${c.faint}next: run ymmv to publish your stack${c.reset}`);
|
|
1130
1600
|
break;
|
|
1601
|
+
}
|
|
1131
1602
|
case "logout":
|
|
1132
1603
|
await logout();
|
|
1133
1604
|
break;
|
|
1134
1605
|
case "help":
|
|
1135
|
-
console.log(
|
|
1606
|
+
console.log(help(palette(colorEnabled())));
|
|
1136
1607
|
break;
|
|
1137
1608
|
case "version":
|
|
1138
1609
|
printVersion();
|