ymmv-cli 0.1.3 → 0.1.5

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 (2) hide show
  1. package/dist/cli.js +111 -11
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -11,13 +11,22 @@ 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(() => ({}));
18
22
  if (res.status === 503) {
19
23
  throw new Error(body.message ?? "GitHub is unavailable \u2014 run `ymmv login` again shortly.");
20
24
  }
25
+ if (res.status === 429) {
26
+ const retry = res.headers.get("retry-after");
27
+ const msg = body.message ?? "too many login attempts \u2014 slow down and try again shortly";
28
+ throw new Error(retry ? `${msg} (retry in ${retry}s)` : msg);
29
+ }
21
30
  throw new Error(`login failed: ${res.status} ${body.error ?? ""}`.trim());
22
31
  }
23
32
  return await res.json();
@@ -25,7 +34,10 @@ async function mintYmmvToken(accessToken) {
25
34
  async function revokeYmmvToken(token) {
26
35
  const res = await fetch(`${BASE}/api/v1/auth/logout`, {
27
36
  method: "POST",
28
- 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"
29
41
  });
30
42
  if (!res.ok) throw new Error(`logout failed: ${res.status}`);
31
43
  const body = await res.json().catch(() => ({}));
@@ -104,6 +116,77 @@ function diff(mine, theirs) {
104
116
  };
105
117
  }
106
118
 
119
+ // ../shared/dist/github.js
120
+ var GITHUB_CLIENT_ID = "Ov23liMoD29eizQcN1KZ";
121
+
122
+ // ../shared/dist/types.js
123
+ var SCHEMA_VERSION = 1;
124
+
125
+ // ../shared/dist/parse.js
126
+ var MAX_PARSE_ENTRIES = 256;
127
+ var MAX_PARSE_EXTRAS = 256;
128
+ var MAX_PARSE_VALUE = 4096;
129
+ var MAX_PARSE_LABEL = 4096;
130
+ var ProfileParseError = class extends Error {
131
+ constructor(message) {
132
+ super(message);
133
+ this.name = "ProfileParseError";
134
+ }
135
+ };
136
+ function isRecord(value) {
137
+ return typeof value === "object" && value !== null;
138
+ }
139
+ function parseProfile(raw) {
140
+ if (!isRecord(raw))
141
+ throw new ProfileParseError("profile is not an object");
142
+ if (raw.schema_version !== SCHEMA_VERSION) {
143
+ throw new ProfileParseError(`unsupported schema_version: ${String(raw.schema_version)}`);
144
+ }
145
+ if (typeof raw.handle !== "string")
146
+ throw new ProfileParseError("handle is not a string");
147
+ if (typeof raw.updated_at !== "string")
148
+ throw new ProfileParseError("updated_at is not a string");
149
+ if (!Array.isArray(raw.entries))
150
+ throw new ProfileParseError("entries is not an array");
151
+ if (raw.entries.length > MAX_PARSE_ENTRIES) {
152
+ throw new ProfileParseError(`too many entries (>${MAX_PARSE_ENTRIES})`);
153
+ }
154
+ const entries = [];
155
+ for (let i = 0; i < raw.entries.length; i++) {
156
+ const entry = raw.entries[i];
157
+ if (!isRecord(entry) || typeof entry.key !== "string" || typeof entry.value !== "string") {
158
+ throw new ProfileParseError(`entry ${i} is not {key,value} strings`);
159
+ }
160
+ if (entry.value.length > MAX_PARSE_VALUE) {
161
+ throw new ProfileParseError(`entry ${i} value exceeds ${MAX_PARSE_VALUE} chars`);
162
+ }
163
+ entries.push({ key: entry.key, value: entry.value });
164
+ }
165
+ if (!Array.isArray(raw.extras))
166
+ throw new ProfileParseError("extras is not an array");
167
+ if (raw.extras.length > MAX_PARSE_EXTRAS) {
168
+ throw new ProfileParseError(`too many extras (>${MAX_PARSE_EXTRAS})`);
169
+ }
170
+ const extras = [];
171
+ for (let i = 0; i < raw.extras.length; i++) {
172
+ const extra = raw.extras[i];
173
+ if (!isRecord(extra) || typeof extra.label !== "string" || typeof extra.value !== "string") {
174
+ throw new ProfileParseError(`extra ${i} is not {label,value} strings`);
175
+ }
176
+ if (extra.label.length > MAX_PARSE_LABEL || extra.value.length > MAX_PARSE_VALUE) {
177
+ throw new ProfileParseError(`extra ${i} exceeds the length ceiling`);
178
+ }
179
+ extras.push({ label: extra.label, value: extra.value });
180
+ }
181
+ return {
182
+ schema_version: SCHEMA_VERSION,
183
+ handle: raw.handle,
184
+ entries,
185
+ extras,
186
+ updated_at: raw.updated_at
187
+ };
188
+ }
189
+
107
190
  // ../shared/dist/reserved.js
108
191
  var RESERVED_ROUTES = ["api", "login", "logout"];
109
192
  var CLI_VERBS = ["login", "logout", "set", "delete", "view", "help"];
@@ -114,9 +197,6 @@ function isValidHandle(handle) {
114
197
  return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
115
198
  }
116
199
 
117
- // ../shared/dist/types.js
118
- var SCHEMA_VERSION = 1;
119
-
120
200
  // src/token-store.ts
121
201
  import { randomUUID } from "crypto";
122
202
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
@@ -127,7 +207,10 @@ function tokenFilePath() {
127
207
  }
128
208
  async function saveToken(data) {
129
209
  const path = tokenFilePath();
130
- await mkdir(dirname(path), { recursive: true });
210
+ const dir = dirname(path);
211
+ await mkdir(dir, { recursive: true, mode: 448 });
212
+ if (process.platform !== "win32") await chmod(dir, 448).catch(() => {
213
+ });
131
214
  const tmp = `${path}.${randomUUID()}.tmp`;
132
215
  const stored = { base: BASE, token: data.token, handle: data.handle };
133
216
  try {
@@ -169,7 +252,6 @@ async function peekBase() {
169
252
  }
170
253
 
171
254
  // src/device-flow.ts
172
- var CLIENT_ID = "Ov23liMoD29eizQcN1KZ";
173
255
  var DEVICE_CODE_URL = "https://github.com/login/device/code";
174
256
  var TOKEN_URL = "https://github.com/login/oauth/access_token";
175
257
  var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -178,7 +260,7 @@ async function requestDeviceCode(deps = {}) {
178
260
  const res = await doFetch(DEVICE_CODE_URL, {
179
261
  method: "POST",
180
262
  headers: { accept: "application/json" },
181
- body: new URLSearchParams({ client_id: CLIENT_ID })
263
+ body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID })
182
264
  });
183
265
  if (!res.ok) {
184
266
  throw new Error(`device code request failed: ${res.status} ${await res.text()}`);
@@ -191,18 +273,36 @@ async function pollForToken(dc, deps = {}) {
191
273
  const now = deps.now ?? Date.now;
192
274
  let interval = dc.interval || 5;
193
275
  const deadline = now() + dc.expires_in * 1e3;
276
+ let transientFailures = 0;
277
+ const MAX_TRANSIENT_FAILURES = 5;
194
278
  while (now() < deadline) {
195
279
  await sleep(interval * 1e3);
196
280
  const res = await doFetch(TOKEN_URL, {
197
281
  method: "POST",
198
282
  headers: { accept: "application/json" },
199
283
  body: new URLSearchParams({
200
- client_id: CLIENT_ID,
284
+ client_id: GITHUB_CLIENT_ID,
201
285
  device_code: dc.device_code,
202
286
  grant_type: "urn:ietf:params:oauth:grant-type:device_code"
203
287
  })
204
288
  });
205
- const tok = await res.json();
289
+ let tok;
290
+ if (res.ok) {
291
+ try {
292
+ tok = await res.json();
293
+ } catch {
294
+ tok = void 0;
295
+ }
296
+ }
297
+ if (tok === void 0) {
298
+ if (++transientFailures >= MAX_TRANSIENT_FAILURES) {
299
+ throw new Error(
300
+ "GitHub isn't responding to the login poll \u2014 check your connection and run `ymmv login` again."
301
+ );
302
+ }
303
+ continue;
304
+ }
305
+ transientFailures = 0;
206
306
  if (tok.access_token) return tok.access_token;
207
307
  switch (tok.error) {
208
308
  case "authorization_pending":
@@ -301,7 +401,7 @@ async function fetchProfileJson(handle) {
301
401
  if (!res.ok) {
302
402
  throw new Error(`fetch failed: ${res.status} ${await res.text()}`);
303
403
  }
304
- return await res.json();
404
+ return parseProfile(await res.json());
305
405
  }
306
406
  async function deleteProfile() {
307
407
  const cred = await ensureLogin();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {