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.
- package/LICENSE +21 -0
- package/README.md +34 -0
- package/dist/index.js +902 -0
- 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
|
+
}
|