ymmv-cli 0.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -0
  3. package/dist/index.js +902 -0
  4. package/package.json +53 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the ymmv.fyi authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # ymmv-cli
2
+
3
+ **Share your dev tool-stack from the terminal.**
4
+
5
+ Editor, OS, shell, terminal, fonts, AI tools — published to a clean page at `ymmv.fyi/<your-handle>`
6
+ in about 10 seconds.
7
+
8
+ ```sh
9
+ npx ymmv-cli # detect your stack, confirm, go live at ymmv.fyi/<you>
10
+ npx ymmv-cli bardisty # view someone's stack in the terminal
11
+ ```
12
+
13
+ Install once for the short `ymmv` command:
14
+
15
+ ```sh
16
+ npm i -g ymmv-cli
17
+ ```
18
+
19
+ First run includes a one-time GitHub sign-in (the device flow you know from `gh` / `npm login`, ~10s).
20
+
21
+ ## Commands
22
+
23
+ - `ymmv` — detect, confirm, and publish (re-run any time to update)
24
+ - `ymmv <handle>` — view a profile, or diff it against yours when you're logged in
25
+ - `ymmv set editor Neovim` — change one value
26
+ - `ymmv set --extra "Keyboard=HHKB"` — add a free-form line of your own
27
+ - `ymmv delete` — remove your profile
28
+ - `ymmv login` / `ymmv logout` — sign in / out
29
+
30
+ Every profile is open JSON too — `GET https://ymmv.fyi/api/v1/u/<handle>`.
31
+
32
+ ## License
33
+
34
+ MIT. Source + issues: <https://github.com/ymmv-fyi/ymmv>.
package/dist/index.js ADDED
@@ -0,0 +1,902 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { readFileSync } from "fs";
5
+ import { pathToFileURL } from "url";
6
+
7
+ // src/config.ts
8
+ var BASE = (process.env.YMMV_API ?? "https://ymmv.fyi").replace(/\/+$/, "");
9
+
10
+ // src/auth-http.ts
11
+ async function mintYmmvToken(accessToken) {
12
+ const res = await fetch(`${BASE}/api/v1/auth/token`, {
13
+ method: "POST",
14
+ headers: { "content-type": "application/json" },
15
+ body: JSON.stringify({ access_token: accessToken })
16
+ });
17
+ if (!res.ok) {
18
+ const body = await res.json().catch(() => ({}));
19
+ if (res.status === 503) {
20
+ throw new Error(body.message ?? "GitHub is unavailable \u2014 run `ymmv login` again shortly.");
21
+ }
22
+ throw new Error(`login failed: ${res.status} ${body.error ?? ""}`.trim());
23
+ }
24
+ return await res.json();
25
+ }
26
+ async function revokeYmmvToken(token) {
27
+ const res = await fetch(`${BASE}/api/v1/auth/logout`, {
28
+ method: "POST",
29
+ headers: { authorization: `Bearer ${token}` }
30
+ });
31
+ if (!res.ok) throw new Error(`logout failed: ${res.status}`);
32
+ const body = await res.json().catch(() => ({}));
33
+ return body.revoked === true;
34
+ }
35
+
36
+ // ../shared/dist/keys.js
37
+ var CURATED_KEYS = [
38
+ "editor",
39
+ "os",
40
+ "shell",
41
+ "terminal",
42
+ "browser",
43
+ "window-manager",
44
+ "font",
45
+ "multiplexer",
46
+ "dotfiles",
47
+ "ai-tool"
48
+ ];
49
+ var KEY_LABELS = {
50
+ editor: "Editor",
51
+ os: "OS",
52
+ shell: "Shell",
53
+ terminal: "Terminal",
54
+ browser: "Browser",
55
+ "window-manager": "Window Manager",
56
+ font: "Font",
57
+ multiplexer: "Multiplexer",
58
+ dotfiles: "Dotfiles",
59
+ "ai-tool": "AI Tool"
60
+ };
61
+ var CURATED_KEY_SET = new Set(CURATED_KEYS);
62
+ function isCuratedKey(value) {
63
+ return CURATED_KEY_SET.has(value);
64
+ }
65
+
66
+ // ../shared/dist/diff.js
67
+ function toLookup(entries) {
68
+ const map = /* @__PURE__ */ new Map();
69
+ for (const entry of entries) {
70
+ map.set(entry.key, entry.value);
71
+ }
72
+ return map;
73
+ }
74
+ function classify(mine, theirs) {
75
+ if (mine !== null && theirs !== null) {
76
+ return mine.trim() === theirs.trim() ? "same" : "changed";
77
+ }
78
+ return mine !== null ? "only_mine" : "only_theirs";
79
+ }
80
+ function diff(mine, theirs) {
81
+ const mineLookup = toLookup(mine.entries);
82
+ const theirsLookup = toLookup(theirs.entries);
83
+ const rows = [];
84
+ let differ = 0;
85
+ let shared = 0;
86
+ for (const key of CURATED_KEYS) {
87
+ const mineValue = mineLookup.get(key) ?? null;
88
+ const theirsValue = theirsLookup.get(key) ?? null;
89
+ if (mineValue === null && theirsValue === null) {
90
+ continue;
91
+ }
92
+ const status = classify(mineValue, theirsValue);
93
+ if (status === "same") {
94
+ shared += 1;
95
+ } else {
96
+ differ += 1;
97
+ }
98
+ rows.push({ key, label: KEY_LABELS[key], mine: mineValue, theirs: theirsValue, status });
99
+ }
100
+ return {
101
+ rows,
102
+ extras: { mine: [...mine.extras], theirs: [...theirs.extras] },
103
+ differ,
104
+ shared
105
+ };
106
+ }
107
+
108
+ // ../shared/dist/reserved.js
109
+ var RESERVED_ROUTES = ["api", "login", "logout"];
110
+ var CLI_VERBS = ["login", "logout", "set", "delete", "view", "help"];
111
+ var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
112
+ var RESERVED_SET = new Set(RESERVED);
113
+ var HANDLE_RE = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$/;
114
+ function isValidHandle(handle) {
115
+ return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
116
+ }
117
+
118
+ // ../shared/dist/types.js
119
+ var SCHEMA_VERSION = 1;
120
+
121
+ // src/token-store.ts
122
+ import { randomUUID } from "crypto";
123
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
124
+ import { dirname, join } from "path";
125
+ import envPaths from "env-paths";
126
+ function tokenFilePath() {
127
+ return join(envPaths("ymmv", { suffix: "" }).config, "token.json");
128
+ }
129
+ async function saveToken(data) {
130
+ const path = tokenFilePath();
131
+ await mkdir(dirname(path), { recursive: true });
132
+ const tmp = `${path}.${randomUUID()}.tmp`;
133
+ const stored = { base: BASE, token: data.token, handle: data.handle };
134
+ try {
135
+ await writeFile(tmp, JSON.stringify(stored), { mode: 384 });
136
+ if (process.platform !== "win32") await chmod(tmp, 384);
137
+ await rename(tmp, path);
138
+ } catch (e) {
139
+ await rm(tmp, { force: true }).catch(() => {
140
+ });
141
+ throw e;
142
+ }
143
+ }
144
+ async function loadToken() {
145
+ let raw;
146
+ try {
147
+ raw = await readFile(tokenFilePath(), "utf8");
148
+ } catch {
149
+ return null;
150
+ }
151
+ let parsed;
152
+ try {
153
+ parsed = JSON.parse(raw);
154
+ } catch {
155
+ return null;
156
+ }
157
+ if (parsed.base !== BASE || typeof parsed.token !== "string") return null;
158
+ return parsed;
159
+ }
160
+ async function deleteToken() {
161
+ await rm(tokenFilePath(), { force: true });
162
+ }
163
+ async function peekBase() {
164
+ try {
165
+ const parsed = JSON.parse(await readFile(tokenFilePath(), "utf8"));
166
+ return typeof parsed.base === "string" ? parsed.base : null;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ // src/device-flow.ts
173
+ var CLIENT_ID = "Ov23liMoD29eizQcN1KZ";
174
+ var DEVICE_CODE_URL = "https://github.com/login/device/code";
175
+ var TOKEN_URL = "https://github.com/login/oauth/access_token";
176
+ var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
177
+ async function requestDeviceCode(deps = {}) {
178
+ const doFetch = deps.fetch ?? globalThis.fetch;
179
+ const res = await doFetch(DEVICE_CODE_URL, {
180
+ method: "POST",
181
+ headers: { accept: "application/json" },
182
+ body: new URLSearchParams({ client_id: CLIENT_ID })
183
+ });
184
+ if (!res.ok) {
185
+ throw new Error(`device code request failed: ${res.status} ${await res.text()}`);
186
+ }
187
+ return await res.json();
188
+ }
189
+ async function pollForToken(dc, deps = {}) {
190
+ const doFetch = deps.fetch ?? globalThis.fetch;
191
+ const sleep = deps.sleep ?? realSleep;
192
+ const now = deps.now ?? Date.now;
193
+ let interval = dc.interval || 5;
194
+ const deadline = now() + dc.expires_in * 1e3;
195
+ while (now() < deadline) {
196
+ await sleep(interval * 1e3);
197
+ const res = await doFetch(TOKEN_URL, {
198
+ method: "POST",
199
+ headers: { accept: "application/json" },
200
+ body: new URLSearchParams({
201
+ client_id: CLIENT_ID,
202
+ device_code: dc.device_code,
203
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
204
+ })
205
+ });
206
+ const tok = await res.json();
207
+ if (tok.access_token) return tok.access_token;
208
+ switch (tok.error) {
209
+ case "authorization_pending":
210
+ break;
211
+ case "slow_down":
212
+ interval = Math.max(tok.interval ?? 0, interval + 5);
213
+ break;
214
+ case "access_denied":
215
+ throw new Error("Authorization denied. Run `ymmv login` to try again.");
216
+ case "expired_token":
217
+ throw new Error("Device code expired \u2014 run `ymmv login` again.");
218
+ default:
219
+ throw new Error(`device flow failed: ${tok.error ?? "unknown error"}`);
220
+ }
221
+ }
222
+ throw new Error("Device code expired \u2014 run `ymmv login` again.");
223
+ }
224
+ async function login(deps = {}) {
225
+ if (!process.stdin.isTTY) {
226
+ throw new Error(
227
+ "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)."
228
+ );
229
+ }
230
+ const dc = await requestDeviceCode(deps);
231
+ console.log(`
232
+ Open ${dc.verification_uri} and enter code: ${dc.user_code}
233
+ `);
234
+ const accessToken = await pollForToken(dc, deps);
235
+ const { token, handle } = await mintYmmvToken(accessToken);
236
+ try {
237
+ await saveToken({ token, handle });
238
+ } catch (e) {
239
+ await revokeYmmvToken(token).catch(() => {
240
+ });
241
+ throw e;
242
+ }
243
+ console.log(
244
+ handle ? ` Logged in as ${handle}.
245
+ ` : " Logged in \u2014 no handle bound (your GitHub username is a reserved word).\n"
246
+ );
247
+ }
248
+
249
+ // src/api.ts
250
+ async function rateLimitMessage(res) {
251
+ const retry = res.headers.get("retry-after");
252
+ let msg = "rate limited \u2014 too many requests";
253
+ try {
254
+ const body = await res.json();
255
+ if (typeof body?.message === "string" && body.message) msg = body.message;
256
+ } catch {
257
+ }
258
+ return retry ? `${msg} (retry in ${retry}s)` : msg;
259
+ }
260
+ async function ensureLogin() {
261
+ const existing = await loadToken();
262
+ if (existing) return existing;
263
+ await login();
264
+ const fresh = await loadToken();
265
+ if (!fresh) throw new Error("login did not persist a token \u2014 run `ymmv login`.");
266
+ return fresh;
267
+ }
268
+ async function publishProfile(profile) {
269
+ const send = (c) => fetch(`${BASE}/api/v1/profile`, {
270
+ method: "POST",
271
+ headers: { "content-type": "application/json", authorization: `Bearer ${c.token}` },
272
+ // Send the login-bound handle, never a caller-guessed one — the official client never claims
273
+ // a handle it doesn't own.
274
+ body: JSON.stringify({ ...profile, handle: c.handle ?? profile.handle }),
275
+ redirect: "manual"
276
+ // a mutation must never follow a redirect into a false success
277
+ });
278
+ let cred = await ensureLogin();
279
+ let res = await send(cred);
280
+ if (res.status === 401 || res.status === 409) {
281
+ if (res.status === 401) await deleteToken();
282
+ await login();
283
+ cred = await ensureLogin();
284
+ res = await send(cred);
285
+ if (res.status === 401) throw new Error("authentication failed \u2014 run `ymmv login`.");
286
+ if (res.status === 409) {
287
+ throw new Error(
288
+ "that handle is taken by another account (your GitHub handle may have been reused)."
289
+ );
290
+ }
291
+ }
292
+ if (res.status === 429) throw new Error(await rateLimitMessage(res));
293
+ if (!res.ok) {
294
+ throw new Error(`publish failed: ${res.status} ${await res.text()}`);
295
+ }
296
+ const data = await res.json();
297
+ console.log(`Published ${data.handle} -> ${BASE}/${data.handle}`);
298
+ }
299
+ async function fetchProfileJson(handle) {
300
+ const res = await fetch(`${BASE}/api/v1/u/${encodeURIComponent(handle)}`);
301
+ if (res.status === 404) return null;
302
+ if (!res.ok) {
303
+ throw new Error(`fetch failed: ${res.status} ${await res.text()}`);
304
+ }
305
+ return await res.json();
306
+ }
307
+ async function deleteProfile() {
308
+ const cred = await ensureLogin();
309
+ const res = await fetch(`${BASE}/api/v1/profile`, {
310
+ method: "DELETE",
311
+ headers: { authorization: `Bearer ${cred.token}` },
312
+ redirect: "manual"
313
+ });
314
+ if (res.status === 401) {
315
+ throw new Error("session expired \u2014 run `ymmv login`, then `ymmv delete` again.");
316
+ }
317
+ if (res.status === 429) throw new Error(await rateLimitMessage(res));
318
+ if (!res.ok) {
319
+ throw new Error(`delete failed: ${res.status} ${await res.text()}`);
320
+ }
321
+ }
322
+
323
+ // src/detect.ts
324
+ function basename(p) {
325
+ const trimmed = p.replace(/[\\/]+$/, "");
326
+ const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
327
+ const name = slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
328
+ return name.replace(/\.exe$/i, "");
329
+ }
330
+ function firstToken(s) {
331
+ return s.trim().split(/\s+/)[0] ?? "";
332
+ }
333
+ var SHELL_NAMES = {
334
+ zsh: "zsh",
335
+ bash: "bash",
336
+ fish: "fish",
337
+ sh: "sh",
338
+ dash: "dash",
339
+ ksh: "ksh",
340
+ tcsh: "tcsh",
341
+ csh: "csh",
342
+ nu: "Nushell",
343
+ nushell: "Nushell",
344
+ pwsh: "PowerShell",
345
+ powershell: "PowerShell",
346
+ elvish: "Elvish",
347
+ xonsh: "xonsh"
348
+ };
349
+ var EDITOR_NAMES = {
350
+ nvim: "Neovim",
351
+ vim: "Vim",
352
+ vi: "Vim",
353
+ code: "VS Code",
354
+ "code-insiders": "VS Code Insiders",
355
+ codium: "VSCodium",
356
+ emacs: "Emacs",
357
+ nano: "Nano",
358
+ hx: "Helix",
359
+ helix: "Helix",
360
+ subl: "Sublime Text",
361
+ micro: "Micro",
362
+ idea: "IntelliJ IDEA",
363
+ zed: "Zed",
364
+ pico: "Pico"
365
+ };
366
+ var TERM_PROGRAMS = {
367
+ "iterm.app": "iTerm2",
368
+ apple_terminal: "Terminal",
369
+ vscode: "VS Code",
370
+ wezterm: "WezTerm",
371
+ ghostty: "Ghostty",
372
+ hyper: "Hyper",
373
+ rio: "Rio",
374
+ kitty: "kitty",
375
+ tabby: "Tabby",
376
+ warpterminal: "Warp"
377
+ };
378
+ function detectOS(env, platform) {
379
+ if (env.WSL_DISTRO_NAME) return `${env.WSL_DISTRO_NAME} (WSL)`;
380
+ switch (platform) {
381
+ case "darwin":
382
+ return "macOS";
383
+ case "win32":
384
+ return "Windows";
385
+ case "linux":
386
+ return "Linux";
387
+ default:
388
+ return platform;
389
+ }
390
+ }
391
+ function detectShell(env, platform) {
392
+ const raw = env.SHELL ?? env.STARSHIP_SHELL;
393
+ if (raw) {
394
+ const key = basename(raw).toLowerCase();
395
+ return SHELL_NAMES[key] ?? basename(raw);
396
+ }
397
+ if (platform === "win32") {
398
+ if (env.PSModulePath) return "PowerShell";
399
+ if (env.ComSpec) return basename(env.ComSpec);
400
+ }
401
+ return void 0;
402
+ }
403
+ function detectTerminal(env) {
404
+ const tp = env.TERM_PROGRAM?.trim();
405
+ if (tp && tp.toLowerCase() !== "tmux") {
406
+ return TERM_PROGRAMS[tp.toLowerCase()] ?? tp;
407
+ }
408
+ if (env.WT_SESSION) return "Windows Terminal";
409
+ if (env.KONSOLE_VERSION) return "Konsole";
410
+ if (env.ALACRITTY_WINDOW_ID || env.ALACRITTY_SOCKET) return "Alacritty";
411
+ return void 0;
412
+ }
413
+ function detectEditor(env) {
414
+ const raw = env.VISUAL ?? env.EDITOR;
415
+ if (!raw) return void 0;
416
+ const bin = basename(firstToken(raw));
417
+ return EDITOR_NAMES[bin.toLowerCase()] ?? bin;
418
+ }
419
+ function detectMultiplexer(env) {
420
+ if (env.TMUX) return "tmux";
421
+ if (env.ZELLIJ) return "Zellij";
422
+ if (env.STY) return "GNU Screen";
423
+ return void 0;
424
+ }
425
+ function detectStack(env, platform) {
426
+ const out = /* @__PURE__ */ new Map();
427
+ const set = (key, value) => {
428
+ if (value?.trim()) out.set(key, value.trim());
429
+ };
430
+ try {
431
+ set("os", detectOS(env, platform));
432
+ set("shell", detectShell(env, platform));
433
+ set("terminal", detectTerminal(env));
434
+ set("editor", detectEditor(env));
435
+ set("multiplexer", detectMultiplexer(env));
436
+ } catch {
437
+ }
438
+ return out;
439
+ }
440
+
441
+ // src/profile-ops.ts
442
+ function buildDefaults(existing, detected) {
443
+ const existingByKey = new Map(
444
+ (existing?.entries ?? []).map((e) => [e.key, e.value])
445
+ );
446
+ const out = /* @__PURE__ */ new Map();
447
+ for (const key of CURATED_KEYS) {
448
+ const value = existingByKey.get(key) ?? detected.get(key);
449
+ if (value?.trim()) out.set(key, value.trim());
450
+ }
451
+ return out;
452
+ }
453
+ function entriesFromMap(map) {
454
+ return CURATED_KEYS.flatMap((key) => {
455
+ const value = map.get(key);
456
+ return value ? [{ key, value }] : [];
457
+ });
458
+ }
459
+ function applySet(existing, target) {
460
+ const entries = (existing?.entries ?? []).map((e) => ({ ...e }));
461
+ const extras = (existing?.extras ?? []).map((x) => ({ ...x }));
462
+ if (target.kind === "curated") {
463
+ const i = entries.findIndex((e) => e.key === target.key);
464
+ const next = { key: target.key, value: target.value };
465
+ if (i >= 0) entries[i] = next;
466
+ else entries.push(next);
467
+ } else {
468
+ const i = extras.findIndex((x) => x.label.toLowerCase() === target.label.toLowerCase());
469
+ const next = { label: target.label, value: target.value };
470
+ if (i >= 0) extras[i] = next;
471
+ else extras.push(next);
472
+ }
473
+ return { entries, extras };
474
+ }
475
+
476
+ // src/render.ts
477
+ var ESC = String.fromCharCode(27);
478
+ var CSI = `${ESC}[`;
479
+ var CODES = {
480
+ amber: `${CSI}93m`,
481
+ // DESIGN: amber == ANSI bright-yellow
482
+ faint: `${CSI}90m`,
483
+ bold: `${CSI}1m`,
484
+ reset: `${CSI}0m`
485
+ };
486
+ var NO_CODES = { amber: "", faint: "", bold: "", reset: "" };
487
+ function palette(color) {
488
+ return color ? CODES : NO_CODES;
489
+ }
490
+ var ESC_INTRODUCERS = `${String.fromCharCode(27)}${String.fromCharCode(155)}`;
491
+ var BEL = String.fromCharCode(7);
492
+ var ANSI_RE = new RegExp(
493
+ `[${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=><~]))`,
494
+ "g"
495
+ );
496
+ var CTRL_RE = new RegExp(
497
+ `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
498
+ "g"
499
+ );
500
+ var BIDI_RE = new RegExp(
501
+ `[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
502
+ "g"
503
+ );
504
+ function sanitizeValue(value) {
505
+ return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
506
+ }
507
+ function useColor(env, isTTY) {
508
+ if (env.NO_COLOR !== void 0) return false;
509
+ if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
510
+ return isTTY;
511
+ }
512
+ function orderedEntries(profile) {
513
+ const byKey = new Map(
514
+ (profile.entries ?? []).map((e) => [e.key, e.value])
515
+ );
516
+ return CURATED_KEYS.flatMap((key) => {
517
+ const value = byKey.get(key);
518
+ return value === void 0 ? [] : [{ label: KEY_LABELS[key], value: sanitizeValue(value) }];
519
+ });
520
+ }
521
+ function renderProfile(profile, opts) {
522
+ const c = palette(opts.color);
523
+ const rows = orderedEntries(profile);
524
+ const extras = (profile.extras ?? []).map((x) => ({
525
+ label: sanitizeValue(x.label),
526
+ value: sanitizeValue(x.value)
527
+ }));
528
+ const labelW = Math.max(
529
+ 0,
530
+ ...rows.map((r) => r.label.length),
531
+ ...extras.map((x) => x.label.length)
532
+ );
533
+ const lines = ["", ` ${c.bold}${sanitizeValue(profile.handle)}${c.reset}`, ""];
534
+ for (const r of rows) {
535
+ lines.push(` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${r.value}`);
536
+ }
537
+ if (extras.length) {
538
+ lines.push("");
539
+ for (const x of extras) {
540
+ lines.push(` ${c.faint}${x.label.padEnd(labelW)}${c.reset} ${x.value}`);
541
+ }
542
+ }
543
+ lines.push("", ` ${c.faint}updated ${sanitizeValue(profile.updated_at)}${c.reset}`, "");
544
+ return lines.join("\n");
545
+ }
546
+ var MISSING = "\u2014";
547
+ function extrasBlock(extras, theirsLabel, mineLabel, c) {
548
+ if (!extras.theirs.length && !extras.mine.length) return [];
549
+ const out = ["", ` ${c.faint}extras${c.reset}`];
550
+ const line = (who, label, value) => ` ${c.faint}${who}${c.reset} ${sanitizeValue(label)} = ${sanitizeValue(value)}`;
551
+ for (const x of extras.theirs) out.push(line(theirsLabel, x.label, x.value));
552
+ for (const x of extras.mine) out.push(line(mineLabel, x.label, x.value));
553
+ return out;
554
+ }
555
+ function renderDiff(result, opts) {
556
+ const c = palette(opts.color);
557
+ const theirsLabel = sanitizeValue(opts.theirsLabel);
558
+ const mineLabel = sanitizeValue(opts.mineLabel);
559
+ const cells = result.rows.map((r) => ({
560
+ label: r.label,
561
+ theirs: r.theirs === null ? MISSING : sanitizeValue(r.theirs),
562
+ mine: r.mine === null ? MISSING : sanitizeValue(r.mine),
563
+ differ: r.status !== "same"
564
+ }));
565
+ const labelW = Math.max(3, ...cells.map((r) => r.label.length));
566
+ const theirsW = Math.max(theirsLabel.length, ...cells.map((r) => r.theirs.length));
567
+ const lines = [""];
568
+ lines.push(
569
+ ` ${c.faint}${"".padEnd(labelW)} ${theirsLabel.padEnd(theirsW)} ${mineLabel}${c.reset}`
570
+ );
571
+ for (const r of cells) {
572
+ if (!opts.color) {
573
+ const sym = r.differ ? "~" : "=";
574
+ lines.push(`${sym} ${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}`);
575
+ } else if (r.differ) {
576
+ lines.push(
577
+ `${c.amber}\u2022${c.reset} ${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${c.amber}${r.mine}${c.reset}`
578
+ );
579
+ } else {
580
+ lines.push(
581
+ ` ${c.faint}${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}${c.reset}`
582
+ );
583
+ }
584
+ }
585
+ lines.push(...extrasBlock(result.extras, theirsLabel, mineLabel, c));
586
+ lines.push(
587
+ "",
588
+ ` ${c.faint}${result.differ} differ \xB7 ${result.shared} shared \u2014 your mileage may vary${c.reset}`,
589
+ ""
590
+ );
591
+ return lines.join("\n");
592
+ }
593
+ function nudge(color) {
594
+ const c = palette(color);
595
+ return `
596
+ ${c.amber}publish yours to diff \u2192${c.reset} run ${c.bold}ymmv${c.reset}
597
+ `;
598
+ }
599
+ function notFound(handle) {
600
+ return `
601
+ no ymmv profile for "${sanitizeValue(handle)}" yet.
602
+ publish one at ymmv.fyi with: npx ymmv-cli
603
+ `;
604
+ }
605
+
606
+ // src/commands.ts
607
+ function colorEnabled() {
608
+ return useColor(process.env, Boolean(process.stdout.isTTY));
609
+ }
610
+ function requireHandle(cred) {
611
+ if (cred.handle) return cred.handle;
612
+ console.error(
613
+ "Your GitHub username is a reserved word, so no handle is bound. Rename on GitHub, then run `ymmv login` again."
614
+ );
615
+ process.exitCode = 1;
616
+ return null;
617
+ }
618
+ function newProfile(handle, entries, extras) {
619
+ return {
620
+ schema_version: SCHEMA_VERSION,
621
+ handle,
622
+ entries,
623
+ extras,
624
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
625
+ };
626
+ }
627
+ async function promptEntries(defaults, prompter) {
628
+ const chosen = /* @__PURE__ */ new Map();
629
+ for (const key of CURATED_KEYS) {
630
+ const answer = (await prompter.ask(KEY_LABELS[key], defaults.get(key))).trim();
631
+ const value = answer === "-" ? "" : answer;
632
+ if (value) chosen.set(key, value);
633
+ }
634
+ return entriesFromMap(chosen);
635
+ }
636
+ async function publish(io) {
637
+ const cred = await ensureLogin();
638
+ const handle = requireHandle(cred);
639
+ if (!handle) return;
640
+ const detected = detectStack(process.env, process.platform);
641
+ const existing = await fetchProfileJson(handle);
642
+ const defaults = buildDefaults(existing, detected);
643
+ let entries = entriesFromMap(defaults);
644
+ const extras = existing?.extras ?? [];
645
+ if (io.interactive && io.prompter) {
646
+ entries = await promptEntries(defaults, io.prompter);
647
+ }
648
+ const profile = newProfile(handle, entries, extras);
649
+ console.log(renderProfile(profile, { color: colorEnabled() }));
650
+ if (io.interactive && io.prompter && !io.yes) {
651
+ const go = await io.prompter.confirm(`Publish to ymmv.fyi/${handle}?`, true);
652
+ if (!go) {
653
+ console.log("Aborted \u2014 nothing published.");
654
+ return;
655
+ }
656
+ }
657
+ await publishProfile(profile);
658
+ }
659
+ async function view(handle) {
660
+ const theirs = await fetchProfileJson(handle);
661
+ const c = colorEnabled();
662
+ if (!theirs) {
663
+ console.log(notFound(handle));
664
+ return;
665
+ }
666
+ const cred = await loadToken();
667
+ if (cred?.handle) {
668
+ const mine = await fetchProfileJson(cred.handle).catch(() => null);
669
+ if (mine && mine.handle.toLowerCase() !== theirs.handle.toLowerCase()) {
670
+ console.log(
671
+ renderDiff(diff(mine, theirs), { color: c, theirsLabel: theirs.handle, mineLabel: "you" })
672
+ );
673
+ return;
674
+ }
675
+ if (!mine) {
676
+ console.log(renderProfile(theirs, { color: c }));
677
+ console.log(nudge(c));
678
+ return;
679
+ }
680
+ }
681
+ console.log(renderProfile(theirs, { color: c }));
682
+ }
683
+ async function runSet(target) {
684
+ const cred = await ensureLogin();
685
+ const handle = requireHandle(cred);
686
+ if (!handle) return;
687
+ const existing = await fetchProfileJson(handle);
688
+ const { entries, extras } = applySet(existing, target);
689
+ await publishProfile(newProfile(handle, entries, extras));
690
+ if (target.kind === "curated") {
691
+ console.log(`Set ${KEY_LABELS[target.key]} = ${target.value}.`);
692
+ } else {
693
+ console.log(`Set extra ${target.label} = ${target.value}.`);
694
+ }
695
+ }
696
+ async function runDelete(io) {
697
+ const cred = await ensureLogin();
698
+ const target = cred.handle ? `ymmv.fyi/${cred.handle}` : "your profile";
699
+ if (!io.yes) {
700
+ if (!io.interactive || !io.prompter) {
701
+ console.error(
702
+ `Refusing to delete ${target} without confirmation. Re-run with -y to confirm: ymmv delete -y`
703
+ );
704
+ process.exitCode = 1;
705
+ return;
706
+ }
707
+ const go = await io.prompter.confirm(`Delete ${target}? This is permanent`, false);
708
+ if (!go) {
709
+ console.log("Cancelled \u2014 nothing deleted.");
710
+ return;
711
+ }
712
+ }
713
+ await deleteProfile();
714
+ await deleteToken();
715
+ console.log(`Deleted ${target}. Run \`ymmv\` to publish again.`);
716
+ }
717
+
718
+ // src/prompt.ts
719
+ import { stdin, stdout } from "process";
720
+ import { createInterface } from "readline/promises";
721
+ function makePrompter() {
722
+ let rl = null;
723
+ const io = () => {
724
+ rl ??= createInterface({ input: stdin, output: stdout });
725
+ return rl;
726
+ };
727
+ return {
728
+ async ask(label, def) {
729
+ const answer = (await io().question(` ${label}${def ? ` [${def}]` : ""}: `)).trim();
730
+ return answer === "" ? def ?? "" : answer;
731
+ },
732
+ async confirm(question, defYes) {
733
+ const answer = (await io().question(` ${question} ${defYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
734
+ if (answer === "") return defYes;
735
+ return answer === "y" || answer === "yes";
736
+ },
737
+ close() {
738
+ rl?.close();
739
+ rl = null;
740
+ }
741
+ };
742
+ }
743
+
744
+ // src/resolve.ts
745
+ var SET_USAGE = 'usage: ymmv set <key> <value> | ymmv set --extra "Label=Value"';
746
+ var EXTRA_USAGE = 'usage: ymmv set --extra "Label=Value"';
747
+ function hasYes(args) {
748
+ return args.includes("-y") || args.includes("--yes");
749
+ }
750
+ function parseSet(rest) {
751
+ const head = rest[0];
752
+ if (head === "--extra" || head === "-e") {
753
+ const spec = rest.slice(1).join(" ").trim();
754
+ const eq = spec.indexOf("=");
755
+ if (eq <= 0) return { kind: "error", message: EXTRA_USAGE };
756
+ const label = spec.slice(0, eq).trim();
757
+ const value2 = spec.slice(eq + 1).trim();
758
+ if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
759
+ return { kind: "set", target: { kind: "extra", label, value: value2 } };
760
+ }
761
+ if (!head) return { kind: "error", message: SET_USAGE };
762
+ if (!isCuratedKey(head)) {
763
+ return {
764
+ kind: "error",
765
+ message: `"${head}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
766
+ For anything else, use: ymmv set --extra "Label=Value".`
767
+ };
768
+ }
769
+ const value = rest.slice(1).join(" ").trim();
770
+ if (!value) return { kind: "error", message: `usage: ymmv set ${head} <value>` };
771
+ return { kind: "set", target: { kind: "curated", key: head, value } };
772
+ }
773
+ function resolveArg(argv) {
774
+ const first = argv[0];
775
+ if (first === "-h" || first === "--help" || first === "help") return { kind: "help" };
776
+ if (first === "-V" || first === "-v" || first === "--version") return { kind: "version" };
777
+ if (first === void 0) return { kind: "publish", yes: false };
778
+ if (first === "-y" || first === "--yes") return { kind: "publish", yes: true };
779
+ if (first === "login") return { kind: "login" };
780
+ if (first === "logout") return { kind: "logout" };
781
+ if (first === "delete") return { kind: "delete", yes: hasYes(argv.slice(1)) };
782
+ if (first === "set") return parseSet(argv.slice(1));
783
+ if (first === "view") {
784
+ const handle = argv[1];
785
+ if (!handle) return { kind: "error", message: "usage: ymmv view <handle>" };
786
+ if (!isValidHandle(handle)) {
787
+ return { kind: "error", message: `"${handle}" is not a valid GitHub handle.` };
788
+ }
789
+ return { kind: "view", handle };
790
+ }
791
+ if (first.startsWith("-")) {
792
+ return { kind: "error", message: `unknown option "${first}". Run \`ymmv help\`.` };
793
+ }
794
+ if (!isValidHandle(first)) {
795
+ return {
796
+ kind: "error",
797
+ message: `"${first}" is not a valid GitHub handle. Run \`ymmv help\`.`
798
+ };
799
+ }
800
+ return { kind: "view", handle: first };
801
+ }
802
+
803
+ // src/index.ts
804
+ var HELP = `ymmv \u2014 terminal-native developer tool-stack profiles (ymmv.fyi)
805
+
806
+ Usage:
807
+ ymmv detect your stack, confirm, and publish your profile
808
+ ymmv <handle> view a profile \u2014 logged in, see the diff vs yours
809
+ ymmv view <handle> explicit view (when a handle collides with a verb)
810
+ ymmv set <key> <value> set one curated key
811
+ ymmv set --extra "L=V" set a free-form extra
812
+ ymmv delete delete your profile (permanent)
813
+ ymmv login | logout GitHub device-flow auth
814
+ ymmv help | --version
815
+
816
+ Curated keys: editor, os, shell, terminal, browser, window-manager, font,
817
+ multiplexer, dotfiles, ai-tool
818
+
819
+ Respects NO_COLOR. Point YMMV_API at a dev Worker to target one.
820
+ Publish your own: npx ymmv-cli`;
821
+ async function logout() {
822
+ const stored = await loadToken();
823
+ if (!stored) {
824
+ const otherBase = await peekBase();
825
+ console.log(
826
+ 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."
827
+ );
828
+ return;
829
+ }
830
+ let revoked;
831
+ try {
832
+ revoked = await revokeYmmvToken(stored.token);
833
+ } catch {
834
+ console.error(
835
+ "Couldn't reach the server to revoke \u2014 your token is still active. Run `ymmv logout` again when connected."
836
+ );
837
+ process.exitCode = 1;
838
+ return;
839
+ }
840
+ await deleteToken();
841
+ console.log(revoked ? "Logged out." : "Logged out (no active session on this server).");
842
+ }
843
+ function printVersion() {
844
+ try {
845
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
846
+ console.log(`ymmv-cli ${pkg.version ?? "unknown"}`);
847
+ } catch {
848
+ console.log("ymmv-cli (version unknown)");
849
+ }
850
+ }
851
+ async function interactive(run, yes) {
852
+ const isTTY = Boolean(process.stdin.isTTY);
853
+ const prompter = isTTY ? makePrompter() : void 0;
854
+ try {
855
+ await run({ interactive: isTTY, prompter, yes });
856
+ } finally {
857
+ prompter?.close();
858
+ }
859
+ }
860
+ async function main(argv) {
861
+ const cmd = resolveArg(argv);
862
+ switch (cmd.kind) {
863
+ case "publish":
864
+ await interactive(publish, cmd.yes);
865
+ break;
866
+ case "view":
867
+ await view(cmd.handle);
868
+ break;
869
+ case "set":
870
+ await runSet(cmd.target);
871
+ break;
872
+ case "delete":
873
+ await interactive(runDelete, cmd.yes);
874
+ break;
875
+ case "login":
876
+ await login();
877
+ break;
878
+ case "logout":
879
+ await logout();
880
+ break;
881
+ case "help":
882
+ console.log(HELP);
883
+ break;
884
+ case "version":
885
+ printVersion();
886
+ break;
887
+ case "error":
888
+ console.error(cmd.message);
889
+ process.exitCode = 1;
890
+ break;
891
+ }
892
+ }
893
+ var invokedPath = process.argv[1];
894
+ if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
895
+ main(process.argv.slice(2)).catch((err) => {
896
+ console.error(err instanceof Error ? err.message : String(err));
897
+ process.exitCode = 1;
898
+ });
899
+ }
900
+ export {
901
+ main
902
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "ymmv-cli",
3
+ "version": "0.1.0",
4
+ "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ymmv": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "the ymmv.fyi authors",
14
+ "homepage": "https://ymmv.fyi",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ymmv-fyi/ymmv.git",
18
+ "directory": "packages/cli"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/ymmv-fyi/ymmv/issues"
22
+ },
23
+ "keywords": [
24
+ "ymmv",
25
+ "uses",
26
+ "dotfiles",
27
+ "developer-tools",
28
+ "profile",
29
+ "diff",
30
+ "cli",
31
+ "terminal"
32
+ ],
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "provenance": true
39
+ },
40
+ "dependencies": {
41
+ "env-paths": "^3"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22",
45
+ "@ymmv/shared": "workspace:*",
46
+ "tsup": "^8"
47
+ },
48
+ "scripts": {
49
+ "build": "tsup",
50
+ "typecheck": "tsc -p tsconfig.json",
51
+ "test": "vitest run"
52
+ }
53
+ }