ymmv-cli 0.1.4 → 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 +101 -8
  2. package/package.json +1 -1
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(() => ({}));
@@ -112,6 +119,74 @@ function diff(mine, theirs) {
112
119
  // ../shared/dist/github.js
113
120
  var GITHUB_CLIENT_ID = "Ov23liMoD29eizQcN1KZ";
114
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
+
115
190
  // ../shared/dist/reserved.js
116
191
  var RESERVED_ROUTES = ["api", "login", "logout"];
117
192
  var CLI_VERBS = ["login", "logout", "set", "delete", "view", "help"];
@@ -122,9 +197,6 @@ function isValidHandle(handle) {
122
197
  return handle.length >= 1 && handle.length <= 39 && HANDLE_RE.test(handle);
123
198
  }
124
199
 
125
- // ../shared/dist/types.js
126
- var SCHEMA_VERSION = 1;
127
-
128
200
  // src/token-store.ts
129
201
  import { randomUUID } from "crypto";
130
202
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
@@ -135,7 +207,10 @@ function tokenFilePath() {
135
207
  }
136
208
  async function saveToken(data) {
137
209
  const path = tokenFilePath();
138
- 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
+ });
139
214
  const tmp = `${path}.${randomUUID()}.tmp`;
140
215
  const stored = { base: BASE, token: data.token, handle: data.handle };
141
216
  try {
@@ -198,6 +273,8 @@ async function pollForToken(dc, deps = {}) {
198
273
  const now = deps.now ?? Date.now;
199
274
  let interval = dc.interval || 5;
200
275
  const deadline = now() + dc.expires_in * 1e3;
276
+ let transientFailures = 0;
277
+ const MAX_TRANSIENT_FAILURES = 5;
201
278
  while (now() < deadline) {
202
279
  await sleep(interval * 1e3);
203
280
  const res = await doFetch(TOKEN_URL, {
@@ -209,7 +286,23 @@ async function pollForToken(dc, deps = {}) {
209
286
  grant_type: "urn:ietf:params:oauth:grant-type:device_code"
210
287
  })
211
288
  });
212
- 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;
213
306
  if (tok.access_token) return tok.access_token;
214
307
  switch (tok.error) {
215
308
  case "authorization_pending":
@@ -308,7 +401,7 @@ async function fetchProfileJson(handle) {
308
401
  if (!res.ok) {
309
402
  throw new Error(`fetch failed: ${res.status} ${await res.text()}`);
310
403
  }
311
- return await res.json();
404
+ return parseProfile(await res.json());
312
405
  }
313
406
  async function deleteProfile() {
314
407
  const cred = await ensureLogin();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.1.4",
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": {