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.
- package/README.md +4 -3
- package/dist/cli.js +209 -55
- 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,
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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) {
|