ymmv-cli 0.1.4 → 0.2.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 +4 -3
  2. package/dist/cli.js +209 -55
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  **Share your dev tool-stack from the terminal.**
4
4
 
5
- Editor, OS, shell, terminal, fonts, AI tools — published to a clean page at `ymmv.fyi/<your-handle>`
6
- in about 10 seconds.
5
+ Editor, OS, shell, terminal, browser, font (and more) — published to a clean
6
+ page at `ymmv.fyi/<handle>` in about 10 seconds.
7
7
 
8
8
  ```sh
9
9
  npx ymmv-cli # detect your stack, confirm, go live at ymmv.fyi/<you>
@@ -16,7 +16,8 @@ Install once for the short `ymmv` command:
16
16
  npm i -g ymmv-cli
17
17
  ```
18
18
 
19
- First run includes a one-time GitHub sign-in (the device flow you know from `gh` / `npm login`, ~10s).
19
+ First run includes a one-time GitHub sign-in (the device flow you know from `gh`
20
+ / `npm login`, ~10s).
20
21
 
21
22
  ## Commands
22
23
 
package/dist/cli.js CHANGED
@@ -11,7 +11,11 @@ async function mintYmmvToken(accessToken) {
11
11
  const res = await fetch(`${BASE}/api/v1/auth/token`, {
12
12
  method: "POST",
13
13
  headers: { "content-type": "application/json" },
14
- body: JSON.stringify({ access_token: accessToken })
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"
15
19
  });
16
20
  if (!res.ok) {
17
21
  const body = await res.json().catch(() => ({}));
@@ -30,7 +34,10 @@ async function mintYmmvToken(accessToken) {
30
34
  async function revokeYmmvToken(token) {
31
35
  const res = await fetch(`${BASE}/api/v1/auth/logout`, {
32
36
  method: "POST",
33
- headers: { authorization: `Bearer ${token}` }
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"
34
41
  });
35
42
  if (!res.ok) throw new Error(`logout failed: ${res.status}`);
36
43
  const body = await res.json().catch(() => ({}));
@@ -67,6 +74,101 @@ function isCuratedKey(value) {
67
74
  return CURATED_KEY_SET.has(value);
68
75
  }
69
76
 
77
+ // ../shared/dist/tools.js
78
+ var TOOLS = [
79
+ // ── editor (envTokens mirror EDITOR_NAMES) ────────────────────────────────
80
+ { key: "editor", canonical: "Neovim", envTokens: ["nvim"], aliases: ["nvim"] },
81
+ { key: "editor", canonical: "Vim", envTokens: ["vim", "vi"] },
82
+ // `vi` is a detector default only, not a diff alias
83
+ {
84
+ key: "editor",
85
+ canonical: "VS Code",
86
+ envTokens: ["code"],
87
+ // NOT "code": elementary OS ships a distinct editor literally named "Code", so bare "code"
88
+ // is ambiguous as a diff alias (it stays an envToken — detecting $EDITOR=code as VS Code is fine).
89
+ aliases: ["vscode", "vsc", "visual studio code"]
90
+ },
91
+ { key: "editor", canonical: "VS Code Insiders", envTokens: ["code-insiders"] },
92
+ { key: "editor", canonical: "VSCodium", envTokens: ["codium"] },
93
+ { key: "editor", canonical: "Emacs", envTokens: ["emacs"] },
94
+ { key: "editor", canonical: "Nano", envTokens: ["nano"] },
95
+ { key: "editor", canonical: "Helix", envTokens: ["hx", "helix"], aliases: ["hx"] },
96
+ { key: "editor", canonical: "Sublime Text", envTokens: ["subl"], aliases: ["subl", "sublime"] },
97
+ { key: "editor", canonical: "Micro", envTokens: ["micro"] },
98
+ { key: "editor", canonical: "IntelliJ IDEA", envTokens: ["idea"], aliases: ["idea", "intellij"] },
99
+ { key: "editor", canonical: "Zed", envTokens: ["zed"] },
100
+ { key: "editor", canonical: "Pico", envTokens: ["pico"] },
101
+ // ── shell (envTokens mirror SHELL_NAMES) ──────────────────────────────────
102
+ { key: "shell", canonical: "zsh", envTokens: ["zsh"] },
103
+ { key: "shell", canonical: "bash", envTokens: ["bash"] },
104
+ { key: "shell", canonical: "fish", envTokens: ["fish"] },
105
+ { key: "shell", canonical: "sh", envTokens: ["sh"] },
106
+ { key: "shell", canonical: "dash", envTokens: ["dash"] },
107
+ { key: "shell", canonical: "ksh", envTokens: ["ksh"] },
108
+ { key: "shell", canonical: "tcsh", envTokens: ["tcsh"] },
109
+ { key: "shell", canonical: "csh", envTokens: ["csh"] },
110
+ { key: "shell", canonical: "Nushell", envTokens: ["nu", "nushell"], aliases: ["nu"] },
111
+ { key: "shell", canonical: "PowerShell", envTokens: ["pwsh", "powershell"], aliases: ["pwsh"] },
112
+ { key: "shell", canonical: "Elvish", envTokens: ["elvish"] },
113
+ { key: "shell", canonical: "xonsh", envTokens: ["xonsh"] },
114
+ // ── terminal (envTokens mirror TERM_PROGRAMS) ─────────────────────────────
115
+ { key: "terminal", canonical: "iTerm2", envTokens: ["iterm.app"], aliases: ["iterm"] },
116
+ { key: "terminal", canonical: "Terminal", envTokens: ["apple_terminal"] },
117
+ // "terminal" too generic to alias
118
+ { key: "terminal", canonical: "VS Code", envTokens: ["vscode"] },
119
+ // integrated terminal ($TERM_PROGRAM=vscode)
120
+ { key: "terminal", canonical: "WezTerm", envTokens: ["wezterm"] },
121
+ { key: "terminal", canonical: "Ghostty", envTokens: ["ghostty"] },
122
+ { key: "terminal", canonical: "Hyper", envTokens: ["hyper"] },
123
+ { key: "terminal", canonical: "Rio", envTokens: ["rio"] },
124
+ { key: "terminal", canonical: "kitty", envTokens: ["kitty"] },
125
+ { key: "terminal", canonical: "Tabby", envTokens: ["tabby"] },
126
+ { key: "terminal", canonical: "Warp", envTokens: ["warpterminal"] },
127
+ // `warpterminal` env id ≠ typed "Warp"
128
+ // ── 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
+ { key: "os", canonical: "macOS", aliases: ["osx", "os x", "mac"] },
134
+ { key: "os", canonical: "Windows", aliases: ["win"] },
135
+ { key: "multiplexer", canonical: "GNU Screen", aliases: ["screen"] }
136
+ // matches detector's "GNU Screen"
137
+ ];
138
+
139
+ // ../shared/dist/normalize.js
140
+ var WS = /\s+/g;
141
+ function fold(value) {
142
+ return value.toLowerCase().normalize("NFC").replace(WS, "");
143
+ }
144
+ function buildAliases(tools) {
145
+ const out = {};
146
+ for (const tool of tools) {
147
+ const target = fold(tool.canonical);
148
+ let bucket = out[tool.key];
149
+ if (bucket === void 0) {
150
+ bucket = {};
151
+ out[tool.key] = bucket;
152
+ }
153
+ for (const variant of [tool.canonical, ...tool.aliases ?? []]) {
154
+ const folded = fold(variant);
155
+ if (folded === target)
156
+ continue;
157
+ bucket[folded] = target;
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+ var ALIASES = buildAliases(TOOLS);
163
+ var RAW_COMPARE_KEYS = /* @__PURE__ */ new Set(["dotfiles"]);
164
+ function canonical(key, value) {
165
+ if (RAW_COMPARE_KEYS.has(key))
166
+ return value.trim();
167
+ const folded = fold(value);
168
+ const hit = ALIASES[key]?.[folded];
169
+ return typeof hit === "string" ? hit : folded;
170
+ }
171
+
70
172
  // ../shared/dist/diff.js
71
173
  function toLookup(entries) {
72
174
  const map = /* @__PURE__ */ new Map();
@@ -75,9 +177,9 @@ function toLookup(entries) {
75
177
  }
76
178
  return map;
77
179
  }
78
- function classify(mine, theirs) {
180
+ function classify(key, mine, theirs) {
79
181
  if (mine !== null && theirs !== null) {
80
- return mine.trim() === theirs.trim() ? "same" : "changed";
182
+ return canonical(key, mine) === canonical(key, theirs) ? "same" : "changed";
81
183
  }
82
184
  return mine !== null ? "only_mine" : "only_theirs";
83
185
  }
@@ -93,7 +195,7 @@ function diff(mine, theirs) {
93
195
  if (mineValue === null && theirsValue === null) {
94
196
  continue;
95
197
  }
96
- const status = classify(mineValue, theirsValue);
198
+ const status = classify(key, mineValue, theirsValue);
97
199
  if (status === "same") {
98
200
  shared += 1;
99
201
  } else {
@@ -112,6 +214,74 @@ function diff(mine, theirs) {
112
214
  // ../shared/dist/github.js
113
215
  var GITHUB_CLIENT_ID = "Ov23liMoD29eizQcN1KZ";
114
216
 
217
+ // ../shared/dist/types.js
218
+ var SCHEMA_VERSION = 1;
219
+
220
+ // ../shared/dist/parse.js
221
+ var MAX_PARSE_ENTRIES = 256;
222
+ var MAX_PARSE_EXTRAS = 256;
223
+ var MAX_PARSE_VALUE = 4096;
224
+ var MAX_PARSE_LABEL = 4096;
225
+ var ProfileParseError = class extends Error {
226
+ constructor(message) {
227
+ super(message);
228
+ this.name = "ProfileParseError";
229
+ }
230
+ };
231
+ function isRecord(value) {
232
+ return typeof value === "object" && value !== null;
233
+ }
234
+ function parseProfile(raw) {
235
+ if (!isRecord(raw))
236
+ throw new ProfileParseError("profile is not an object");
237
+ if (raw.schema_version !== SCHEMA_VERSION) {
238
+ throw new ProfileParseError(`unsupported schema_version: ${String(raw.schema_version)}`);
239
+ }
240
+ if (typeof raw.handle !== "string")
241
+ throw new ProfileParseError("handle is not a string");
242
+ if (typeof raw.updated_at !== "string")
243
+ throw new ProfileParseError("updated_at is not a string");
244
+ if (!Array.isArray(raw.entries))
245
+ throw new ProfileParseError("entries is not an array");
246
+ if (raw.entries.length > MAX_PARSE_ENTRIES) {
247
+ throw new ProfileParseError(`too many entries (>${MAX_PARSE_ENTRIES})`);
248
+ }
249
+ const entries = [];
250
+ for (let i = 0; i < raw.entries.length; i++) {
251
+ const entry = raw.entries[i];
252
+ if (!isRecord(entry) || typeof entry.key !== "string" || typeof entry.value !== "string") {
253
+ throw new ProfileParseError(`entry ${i} is not {key,value} strings`);
254
+ }
255
+ if (entry.value.length > MAX_PARSE_VALUE) {
256
+ throw new ProfileParseError(`entry ${i} value exceeds ${MAX_PARSE_VALUE} chars`);
257
+ }
258
+ entries.push({ key: entry.key, value: entry.value });
259
+ }
260
+ if (!Array.isArray(raw.extras))
261
+ throw new ProfileParseError("extras is not an array");
262
+ if (raw.extras.length > MAX_PARSE_EXTRAS) {
263
+ throw new ProfileParseError(`too many extras (>${MAX_PARSE_EXTRAS})`);
264
+ }
265
+ const extras = [];
266
+ for (let i = 0; i < raw.extras.length; i++) {
267
+ const extra = raw.extras[i];
268
+ if (!isRecord(extra) || typeof extra.label !== "string" || typeof extra.value !== "string") {
269
+ throw new ProfileParseError(`extra ${i} is not {label,value} strings`);
270
+ }
271
+ if (extra.label.length > MAX_PARSE_LABEL || extra.value.length > MAX_PARSE_VALUE) {
272
+ throw new ProfileParseError(`extra ${i} exceeds the length ceiling`);
273
+ }
274
+ extras.push({ label: extra.label, value: extra.value });
275
+ }
276
+ return {
277
+ schema_version: SCHEMA_VERSION,
278
+ handle: raw.handle,
279
+ entries,
280
+ extras,
281
+ updated_at: raw.updated_at
282
+ };
283
+ }
284
+
115
285
  // ../shared/dist/reserved.js
116
286
  var RESERVED_ROUTES = ["api", "login", "logout"];
117
287
  var CLI_VERBS = ["login", "logout", "set", "delete", "view", "help"];
@@ -122,9 +292,6 @@ function isValidHandle(handle) {
122
292
  return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
123
293
  }
124
294
 
125
- // ../shared/dist/types.js
126
- var SCHEMA_VERSION = 1;
127
-
128
295
  // src/token-store.ts
129
296
  import { randomUUID } from "crypto";
130
297
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
@@ -135,7 +302,10 @@ function tokenFilePath() {
135
302
  }
136
303
  async function saveToken(data) {
137
304
  const path = tokenFilePath();
138
- await mkdir(dirname(path), { recursive: true });
305
+ const dir = dirname(path);
306
+ await mkdir(dir, { recursive: true, mode: 448 });
307
+ if (process.platform !== "win32") await chmod(dir, 448).catch(() => {
308
+ });
139
309
  const tmp = `${path}.${randomUUID()}.tmp`;
140
310
  const stored = { base: BASE, token: data.token, handle: data.handle };
141
311
  try {
@@ -198,6 +368,8 @@ async function pollForToken(dc, deps = {}) {
198
368
  const now = deps.now ?? Date.now;
199
369
  let interval = dc.interval || 5;
200
370
  const deadline = now() + dc.expires_in * 1e3;
371
+ let transientFailures = 0;
372
+ const MAX_TRANSIENT_FAILURES = 5;
201
373
  while (now() < deadline) {
202
374
  await sleep(interval * 1e3);
203
375
  const res = await doFetch(TOKEN_URL, {
@@ -209,7 +381,23 @@ async function pollForToken(dc, deps = {}) {
209
381
  grant_type: "urn:ietf:params:oauth:grant-type:device_code"
210
382
  })
211
383
  });
212
- const tok = await res.json();
384
+ let tok;
385
+ if (res.ok) {
386
+ try {
387
+ tok = await res.json();
388
+ } catch {
389
+ tok = void 0;
390
+ }
391
+ }
392
+ if (tok === void 0) {
393
+ if (++transientFailures >= MAX_TRANSIENT_FAILURES) {
394
+ throw new Error(
395
+ "GitHub isn't responding to the login poll \u2014 check your connection and run `ymmv login` again."
396
+ );
397
+ }
398
+ continue;
399
+ }
400
+ transientFailures = 0;
213
401
  if (tok.access_token) return tok.access_token;
214
402
  switch (tok.error) {
215
403
  case "authorization_pending":
@@ -308,7 +496,7 @@ async function fetchProfileJson(handle) {
308
496
  if (!res.ok) {
309
497
  throw new Error(`fetch failed: ${res.status} ${await res.text()}`);
310
498
  }
311
- return await res.json();
499
+ return parseProfile(await res.json());
312
500
  }
313
501
  async function deleteProfile() {
314
502
  const cred = await ensureLogin();
@@ -336,51 +524,17 @@ function basename(p) {
336
524
  function firstToken(s) {
337
525
  return s.trim().split(/\s+/)[0] ?? "";
338
526
  }
339
- var SHELL_NAMES = {
340
- zsh: "zsh",
341
- bash: "bash",
342
- fish: "fish",
343
- sh: "sh",
344
- dash: "dash",
345
- ksh: "ksh",
346
- tcsh: "tcsh",
347
- csh: "csh",
348
- nu: "Nushell",
349
- nushell: "Nushell",
350
- pwsh: "PowerShell",
351
- powershell: "PowerShell",
352
- elvish: "Elvish",
353
- xonsh: "xonsh"
354
- };
355
- var EDITOR_NAMES = {
356
- nvim: "Neovim",
357
- vim: "Vim",
358
- vi: "Vim",
359
- code: "VS Code",
360
- "code-insiders": "VS Code Insiders",
361
- codium: "VSCodium",
362
- emacs: "Emacs",
363
- nano: "Nano",
364
- hx: "Helix",
365
- helix: "Helix",
366
- subl: "Sublime Text",
367
- micro: "Micro",
368
- idea: "IntelliJ IDEA",
369
- zed: "Zed",
370
- pico: "Pico"
371
- };
372
- var TERM_PROGRAMS = {
373
- "iterm.app": "iTerm2",
374
- apple_terminal: "Terminal",
375
- vscode: "VS Code",
376
- wezterm: "WezTerm",
377
- ghostty: "Ghostty",
378
- hyper: "Hyper",
379
- rio: "Rio",
380
- kitty: "kitty",
381
- tabby: "Tabby",
382
- warpterminal: "Warp"
527
+ var namesFor = (key) => {
528
+ const map = {};
529
+ for (const tool of TOOLS) {
530
+ if (tool.key !== key) continue;
531
+ for (const token of tool.envTokens ?? []) map[token] = tool.canonical;
532
+ }
533
+ return map;
383
534
  };
535
+ var SHELL_NAMES = namesFor("shell");
536
+ var EDITOR_NAMES = namesFor("editor");
537
+ var TERM_PROGRAMS = namesFor("terminal");
384
538
  function detectOS(env, platform) {
385
539
  if (env.WSL_DISTRO_NAME) return `${env.WSL_DISTRO_NAME} (WSL)`;
386
540
  switch (platform) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {