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.
Files changed (3) hide show
  1. package/README.md +34 -12
  2. package/dist/cli.js +781 -310
  3. 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 doFetch(DEVICE_CODE_URL, {
356
- method: "POST",
357
- headers: { accept: "application/json" },
358
- body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID })
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 await res.json();
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
- const res = await doFetch(TOKEN_URL, {
376
- method: "POST",
377
- headers: { accept: "application/json" },
378
- body: new URLSearchParams({
379
- client_id: GITHUB_CLIENT_ID,
380
- device_code: dc.device_code,
381
- grant_type: "urn:ietf:params:oauth:grant-type:device_code"
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.ok) {
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
- "GitHub isn't responding to the login poll \u2014 check your connection and run `ymmv login` again."
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 \u2014 run `ymmv login` again.");
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 \u2014 run `ymmv login` again.");
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 \u2014 run `ymmv login` in a real terminal (a piped or CI shell can't complete the GitHub device flow)."
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
- console.log(`
426
- Open ${dc.verification_uri} and enter code: ${dc.user_code}
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 \u2014 no handle bound (your GitHub username is a reserved word).\n"
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 \u2014 too many requests";
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("login did not persist a token \u2014 run `ymmv login`.");
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) => fetch(`${BASE}/api/v1/profile`, {
594
- method: "POST",
595
- headers: { "content-type": "application/json", authorization: `Bearer ${c.token}` },
596
- // Send the login-bound handle, never a caller-guessed one — the official client never claims
597
- // a handle it doesn't own.
598
- body: JSON.stringify({ ...profile, handle: c.handle ?? profile.handle }),
599
- redirect: "manual"
600
- // a mutation must never follow a redirect into a false success
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
- "the stored login changed while this command was running \u2014 re-run it under the current account."
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
- if (res.status === 401) await deleteToken();
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("authentication failed \u2014 run `ymmv login`.");
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
- console.log(`Published ${shown} -> ${BASE}/${shown}`);
842
+ return { handle: shown, url: `${BASE}/${shown}` };
628
843
  }
629
844
  async function fetchProfileJson(handle) {
630
- const res = await fetch(`${BASE}/api/v1/u/${encodeURIComponent(handle)}`);
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 fetch(`${BASE}/api/v1/profile`, {
640
- method: "DELETE",
641
- headers: { authorization: `Bearer ${cred.token}` },
642
- redirect: "manual"
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("session expired \u2014 run `ymmv login`, then `ymmv delete` again.");
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
- function detectOS(env, platform) {
675
- if (env.WSL_DISTRO_NAME) return `${env.WSL_DISTRO_NAME} (WSL)`;
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 (!raw) return void 0;
712
- const bin = basename(firstToken(raw));
713
- return EDITOR_NAMES[bin.toLowerCase()] ?? bin;
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 detectStack(env, platform) {
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/commands.ts
795
- function colorEnabled() {
796
- return useColor(process.env, Boolean(process.stdout.isTTY));
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
- `this login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}" \u2014 run \`ymmv login\` to refresh, then retry.`
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 entriesFromMap(chosen);
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
- let entries = entriesFromMap(defaults);
1286
+ const carried = unknownEntries(existing);
840
1287
  const extras = existing?.extras ?? [];
841
- if (io.interactive && io.prompter) {
842
- entries = await promptEntries(defaults, io.prompter);
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
- const profile = newProfile(handle, entries, extras);
845
- console.log(renderProfile(profile, { color: colorEnabled() }));
846
- if (io.interactive && io.prompter && !io.yes) {
847
- const go = await io.prompter.confirm(`Publish to ymmv.fyi/${handle}?`, true);
848
- if (!go) {
849
- console.log("Aborted \u2014 nothing published.");
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
- if (target.kind === "curated") {
888
- console.log(`Set ${KEY_LABELS[target.key]} = ${target.value}.`);
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 \u2014 run `ymmv` to publish one.");
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
- if (target.kind === "curated") {
912
- console.log(`Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").`);
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 ? `ymmv.fyi/${cred.handle}` : "your profile";
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
- const go = await io.prompter.confirm(`Delete ${target}? This is permanent`, false);
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 \u2014 nothing deleted.");
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: `unknown option "${first}". Run \`ymmv help\`.` };
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 HELP = `ymmv \u2014 terminal-native developer tool-stack profiles (ymmv.fyi)
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 <handle> view a profile \u2014 logged in, see the diff vs yours
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: editor, os, shell, terminal, browser, window-manager, font,
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 \u2014 set YMMV_API to that to log out of it).` : "Not logged in."
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 \u2014 your token is still active. Run `ymmv logout` again when connected."
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(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
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(HELP);
1606
+ console.log(help(palette(colorEnabled())));
1136
1607
  break;
1137
1608
  case "version":
1138
1609
  printVersion();