@1e0zj/dsh-plugin-mall 0.4.2 → 0.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -499,10 +499,10 @@ window.__ModuleLoader__.load({
499
499
  busy: props.approving === job.spec,
500
500
  onApprove: function (names) {
501
501
  // 不先 drop:旧条目由重试任务的 track(carryFromId) 原子接管
502
- // (撤条目 + 日志接续一拍完成)。先删的话,call("install")
503
- // 要走数秒(重试还会重跑一次隔离预检),面板会空白一段,
504
- // 「批准后任务消失、开始安装才冒出来」的割裂就是这么来的。
505
- // 等待期间按钮由 approving 态显示「继续中…」。
502
+ // (撤条目 + 日志接续一拍完成)。install RPC 现在只做本地校验、
503
+ // 立刻返回新 job id,但保留这个次序仍然是它最稳的形态——
504
+ // 万一 RPC 失败,旧条目和日志还在。等待期间按钮由 approving
505
+ // 态显示「继续中…」。
506
506
  props.onApprove(job.spec, names, job.approvalToken, id);
507
507
  },
508
508
  onDismiss: function () { props.onDismiss(id); },
@@ -1056,7 +1056,10 @@ window.__ModuleLoader__.load({
1056
1056
  var spec = preflight.spec;
1057
1057
  var carry = preflight.jobId;
1058
1058
  setPreflight(null);
1059
- doRawInstall(spec, { acceptWarnings: true }, carry);
1059
+ // consentDigest 来自预检 job extras,装的时候与当前报告比对:
1060
+ // 用户点「继续」到安装真正开跑之间候选包或 profile 变了,
1061
+ // 布尔同意不得沿用,要重新看新的警告。
1062
+ doRawInstall(spec, { acceptWarnings: true, acceptedReportDigest: preflight.report.consentDigest }, carry);
1060
1063
  },
1061
1064
  onClose: function () {
1062
1065
  if (preflight && preflight.spec) delete approvalTokensRef.current[preflight.spec];
package/src/github.js CHANGED
@@ -64,7 +64,29 @@ async function requestJson(path, { apiBase, token, signal }) {
64
64
  }
65
65
  const remaining = response.headers.get("x-ratelimit-remaining");
66
66
  const resetAt = response.headers.get("x-ratelimit-reset");
67
- const body = await response.json().catch(() => undefined);
67
+ // 读体阶段的三种失败必须分开,不能一把 catch undefined
68
+ // 调用方取消(响应头已到、body 读到一半浏览器断开)→ AbortError 上抛;
69
+ // 内部超时打断读体 → 与请求阶段超时同待遇,退避后重试;
70
+ // 响应体根本不是 JSON(镜像的 HTML 错误页等)→ 维持 undefined,交给
71
+ // 下面的状态码分支。
72
+ // 此前这里是无差别 .catch(() => undefined):mid-body 取消被吞掉,200 响应
73
+ // 返回 undefined,search 拿到的是 TypeError 而不是取消;4xx 路径则把一次
74
+ // 取消谎报成 not found。
75
+ let body;
76
+ try {
77
+ body = await response.json();
78
+ } catch (error) {
79
+ if (error?.name === "AbortError" && signal?.aborted) throw error;
80
+ // 读体超时只在成功响应上重试。4xx 的结论在读体之前就已确定
81
+ // (见上方契约:4xx 判定式失败,从不重试)——404 有 404 的报法、
82
+ // 403 限流有专用诊断,读不出 body 不改变任何一件事;重试三次
83
+ // 只会把正确答案换成一句超时。
84
+ if (error?.name === "TimeoutError" && response.ok) {
85
+ lastError = new Error(`GitHub API response body timed out after ${REQUEST_TIMEOUT / 1000}s (attempt ${attempt + 1})`);
86
+ continue;
87
+ }
88
+ body = undefined;
89
+ }
68
90
  if (response.status === 403 && remaining === "0" && resetAt !== null) {
69
91
  const reset = new Date(Number(resetAt) * 1000).toISOString();
70
92
  throw new Error(`GitHub API rate limit exceeded; resets at ${reset} (UTC). Set GITHUB_TOKEN or DSH_MARKET_GITHUB_TOKEN for a higher limit.`);
@@ -173,16 +195,19 @@ function normalizeRegistry(registry) {
173
195
  * @param name - the npm package name.
174
196
  * @param options - `registry` defaults to npmjs; pass what pnpm installs from.
175
197
  * `signal` cancels the request (and, critically, keeps the cancellation out
176
- * of the cache — see below).
198
+ * of the cache — see below). `fresh` skips the read side of the cache: a
199
+ * cached "current version" is stale by construction, and the preflight
200
+ * staleness check exists precisely to compare against what the registry
201
+ * says NOW (the write side still populates the cache for other callers).
177
202
  * @returns `{latest, repositoryUrl, hostDeps}`, or null when unknown/unreachable.
178
203
  */
179
- export async function npmPackageInfo(name, { registry, signal } = {}) {
204
+ export async function npmPackageInfo(name, { registry, signal, fresh = false } = {}) {
180
205
  const clean = String(name ?? "").trim();
181
206
  if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
182
207
  const base = normalizeRegistry(registry);
183
208
  const key = `${base}|${clean}`;
184
209
  const cached = npmCache.get(key);
185
- if (cached !== undefined && Date.now() - cached.at < NPM_CACHE_TTL) return cached.info;
210
+ if (fresh !== true && cached !== undefined && Date.now() - cached.at < NPM_CACHE_TTL) return cached.info;
186
211
  let info = null;
187
212
  try {
188
213
  // 单版本端点返回 latest 的完整 manifest(dependencies + repository 都在)。
@@ -215,6 +240,44 @@ export async function npmPackageInfo(name, { registry, signal } = {}) {
215
240
  return info;
216
241
  }
217
242
 
243
+ const npmVersionsCache = new Map();
244
+
245
+ /**
246
+ * All published version strings of a package, oldest to newest, for resolving
247
+ * a RANGE spec ("pkg@^1.2.0") to the version pnpm would actually pick today.
248
+ * `/latest` cannot answer that — a new release inside the range is invisible
249
+ * to it until it becomes latest. Fetches the full packument (one request),
250
+ * which is why this lives behind its own TTL like npmPackageInfo. Same
251
+ * cancellation rule: AbortError is rethrown, never cached as an empty answer.
252
+ * @returns string[], or null when unreachable/unknown.
253
+ */
254
+ export async function npmPackageVersions(name, { registry, signal, fresh = false } = {}) {
255
+ const clean = String(name ?? "").trim();
256
+ if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
257
+ const base = normalizeRegistry(registry);
258
+ const key = `${base}|${clean}`;
259
+ const cached = npmVersionsCache.get(key);
260
+ if (fresh !== true && cached !== undefined && Date.now() - cached.at < NPM_CACHE_TTL) return cached.versions;
261
+ let versions = null;
262
+ try {
263
+ const response = await fetch(`${base}/${clean.replace("/", "%2F")}`, {
264
+ headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
265
+ signal,
266
+ });
267
+ if (response.ok) {
268
+ const body = await response.json();
269
+ if (body?.versions !== null && typeof body?.versions === "object") {
270
+ versions = Object.keys(body.versions);
271
+ }
272
+ }
273
+ } catch (error) {
274
+ if (error?.name === "AbortError") throw error;
275
+ versions = null;
276
+ }
277
+ npmVersionsCache.set(key, { versions, at: Date.now() });
278
+ return versions;
279
+ }
280
+
218
281
  /**
219
282
  * Rewrite "github:owner/repo" (or "owner/repo") to the npm package name when
220
283
  * that package exists on npm AND its repository URL points back at the repo
@@ -577,6 +640,9 @@ export async function repoInfo({ repo, apiBase, token, signal }) {
577
640
  try {
578
641
  meta = await requestJson(`/repos/${trimmed}`, { apiBase, token, signal });
579
642
  } catch (error) {
643
+ // 取消不是「仓库不存在」:包装会把这个信号翻译成业务错误,调用方据此
644
+ // 报告一个不存在的网络故障。取消原样上抛。
645
+ if (error?.name === "AbortError") throw error;
580
646
  throw new Error(`market_info: repository ${trimmed} not found on GitHub (${error.message})`);
581
647
  }
582
648
  let packageJson;
@@ -585,7 +651,8 @@ export async function repoInfo({ repo, apiBase, token, signal }) {
585
651
  if (typeof contents.content === "string") {
586
652
  packageJson = JSON.parse(Buffer.from(contents.content, "base64").toString("utf8"));
587
653
  }
588
- } catch {
654
+ } catch (error) {
655
+ if (error?.name === "AbortError") throw error; // 取消不是「没有 package.json」
589
656
  packageJson = undefined; // no package.json at the repo root
590
657
  }
591
658
  return {
@@ -687,6 +754,79 @@ if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test
687
754
  }
688
755
  if (clampFailed > 0) process.exit(1);
689
756
  }
757
+ // requestJson 读体阶段的三种失败。打桩 fetch——离线、确定性;abort 在
758
+ // json() 内部发生(不是调用前预 abort),逼近「响应头已到、body 读一半
759
+ // 浏览器断开」的真实时序。
760
+ {
761
+ const realFetch = globalThis.fetch;
762
+ const makeError = (name) => { const error = new Error("synthetic"); error.name = name; return error; };
763
+ const fakeResponse = ({ status, headers, json }) => ({
764
+ ok: status < 400,
765
+ status,
766
+ statusText: status === 404 ? "Not Found" : status === 403 ? "Forbidden" : "OK",
767
+ headers: headers ?? new Headers(),
768
+ json,
769
+ });
770
+ let failed = 0;
771
+ try {
772
+ // 1) mid-body 取消:json() 里才 abort。修复前 .catch(()=>undefined)
773
+ // 把它吞掉——200 变 undefined(search 得 TypeError)、4xx 谎报 not found。
774
+ {
775
+ const controller = new AbortController();
776
+ let threw;
777
+ globalThis.fetch = async () => fakeResponse({
778
+ status: 200,
779
+ json: async () => { controller.abort(); throw makeError("AbortError"); },
780
+ });
781
+ try {
782
+ await requestJson("/repos/owner/repo", { signal: controller.signal });
783
+ } catch (error) { threw = error; }
784
+ if (threw?.name === "AbortError") console.log(" PASS 读体期间取消 → AbortError 上抛(不吞成 undefined)");
785
+ else { failed++; console.log(` FAIL 读体期间取消应抛 AbortError,实得 ${threw ? `${threw.name}: ${threw.message}` : "未抛(body 变 undefined)"}`); }
786
+ }
787
+
788
+ // 2) 成功响应的读体超时 → 记一次失败,退避重试(重试轮换成成功)。
789
+ {
790
+ let attempts = 0;
791
+ globalThis.fetch = async () => fakeResponse({
792
+ status: 200,
793
+ json: async () => { attempts++; if (attempts === 1) throw makeError("TimeoutError"); return { ok: true }; },
794
+ });
795
+ const retried = await requestJson("/repos/owner/repo", {});
796
+ if (retried?.ok === true && attempts === 2) console.log(" PASS 读体超时(200)→ 记一次失败并重试成功");
797
+ else { failed++; console.log(` FAIL 读体超时(200)应重试一次,实得 attempts=${attempts} result=${JSON.stringify(retried)}`); }
798
+ }
799
+
800
+ // 3) 4xx 的结论在读体之前就已确定,读体超时绝不重试——重试只会把
801
+ // 正确的 404 换成一句超时。一次调用、报 404 的报法。
802
+ {
803
+ let calls = 0;
804
+ globalThis.fetch = async () => { calls++; return fakeResponse({ status: 404, json: async () => { throw makeError("TimeoutError"); } }); };
805
+ let threw;
806
+ try {
807
+ await requestJson("/repos/owner/absent", {});
808
+ } catch (error) { threw = error; }
809
+ if (calls === 1 && /GitHub API 404/.test(threw?.message ?? "")) console.log(" PASS 读体超时(404)→ 不重试,保留 404 结论");
810
+ else { failed++; console.log(` FAIL 404+读体超时应一次调用报 404,实得 calls=${calls} error=${threw?.message}`); }
811
+ }
812
+
813
+ // 4) 403 限流同理:专用诊断靠响应头(不依赖 body),必须一次到位。
814
+ {
815
+ let calls = 0;
816
+ const limited = new Headers({ "x-ratelimit-remaining": "0", "x-ratelimit-reset": "4102444800" });
817
+ globalThis.fetch = async () => { calls++; return fakeResponse({ status: 403, headers: limited, json: async () => { throw makeError("TimeoutError"); } }); };
818
+ let threw;
819
+ try {
820
+ await requestJson("/search/repositories", {});
821
+ } catch (error) { threw = error; }
822
+ if (calls === 1 && /rate limit exceeded/.test(threw?.message ?? "")) console.log(" PASS 读体超时(403 限流)→ 不重试,保留限流诊断");
823
+ else { failed++; console.log(` FAIL 403+读体超时应一次调用给限流诊断,实得 calls=${calls} error=${threw?.message}`); }
824
+ }
825
+ } finally {
826
+ globalThis.fetch = realFetch;
827
+ }
828
+ if (failed > 0) process.exit(1);
829
+ }
690
830
  if (process.argv.includes("--offline")) process.exit(0);
691
831
  const apiBase = "https://api.github.com";
692
832
  const result = await searchPlugins({ query: "", perPage: 3, apiBase });