magicrew-auth 0.3.7 → 0.3.13

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/src/tasks.mjs CHANGED
@@ -1,31 +1,32 @@
1
- // 签到 = 自动做完新手福利
1
+ // 签到 = 每日登录积分 + 自动做完新手福利
2
2
  //
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
- // 结论先说:不用浏览器
4
+ // 两套机制,别混
5
5
  // ═══════════════════════════════════════════════════════════════════════
6
6
  //
7
- // 一开始从主 chunk 里只挖到埋点事件名(`onboarding_guide_step_click`),
8
- // 所以以为任务要靠前端导览判定。翻到 `onboarding-CZBI0pQt.js` 才发现
9
- // 真正的接口只有两行:
7
+ // ① 每日登录积分 —— POST /api/v1/login-gift(无 body)
8
+ // 两站同构,幂等,每天一次。国际站 +50/天,国内站 +200/天。
9
+ // 当天已领 → granted:false「今日已领取登录积分」,HTTP 仍 200。
10
+ // **这才是真正的「每日签到」**,也是 0.3.7 及更早版本完全漏掉的那一块。
11
+ // 来源:2026-10-27 用真实网页登录抓包得到(magicrew.ai 的 /login-gift)。
10
12
  //
11
- // GET /api/v1/onboarding/tasks
12
- // PUT /api/v1/onboarding/tasks/{code}/completion
13
- //
14
- // 8 项任务里 7 项 PUT 一下就完成、积分当场到账。只有 `first_ai_work`
15
- // 回 9303「该新人引导任务不支持手动完成」—— 那项得真的跑一次 AI 任务。
16
- //
17
- // 实测(2026-10-27):1240 → 1540,+300,六项全部当场入账。
13
+ // ② 新手福利任务 —— GET /api/v1/onboarding/tasks + PUT .../completion
14
+ // 一次性收益,8 项里 7 项 PUT 即完成,first_ai_work 服务端不收手动完成。
15
+ // ⚠ 国际站这个接口恒返回 {participant_type:null, tasks:[]} —— 是「这个账号
16
+ // 没被登记进引导计划」,不是「已经领完了」。0.3.7 把 0 项当成功上报,
17
+ // 于是 msg 写着「新手福利 0 项已全部完成」,实际上什么都没做。
18
18
  //
19
19
  // ═══════════════════════════════════════════════════════════════════════
20
20
  //
21
- // 所以 magpie 的 auth.checkin 钩子做的事:
22
- // ① 拉任务列表
23
- // ② 把 PENDING 的挨个 PUT(跳过不支持手动的)
24
- // ③ 报积分变化
25
- //
26
- // 每天跑一次,第一次就把 300 分拿完;之后每次都是 done。
21
+ // 每天跑一次,两步都做,任一步失败不影响另一步。
22
+
23
+ import { fetchPoints, claimLoginGift, fetchOnboardingTasks, completeOnboardingTask, fetchFirstWorkspaceId } from "./api.mjs"
24
+ import { isAutoChatSettled, noteAutoChat, noteAutoChatFailure } from "./store.mjs"
27
25
 
28
- import { fetchPoints, fetchOnboardingTasks, completeOnboardingTask } from "./api.mjs"
26
+ /** 站点 id —— 去重标记按站点分文件存 */
27
+ function siteIdOf(host) {
28
+ return host.includes("magicrew") ? "magicrew-intl" : "magicrew"
29
+ }
29
30
 
30
31
  /** 已知不支持手动完成的任务,别再白试 */
31
32
  const NOT_MANUALLY_COMPLETABLE = new Set(["first_ai_work"])
@@ -48,72 +49,266 @@ export async function runCheckin(auth) {
48
49
  return { outcome: "failed", message: `读不到积分:${e?.message ?? e}` }
49
50
  }
50
51
 
52
+ const parts = []
53
+ let claimedAnything = false
54
+ let failed = false
55
+
56
+ // ── ① 每日登录积分 ────────────────────────────────────────────────
57
+ try {
58
+ const gift = await claimLoginGift(h.jwt, h.org, h.host)
59
+ if (gift.granted) {
60
+ claimedAnything = true
61
+ parts.push(`每日登录 +${gift.points}`)
62
+ } else {
63
+ parts.push("每日登录已领过")
64
+ }
65
+ } catch (e) {
66
+ failed = true
67
+ parts.push(`每日登录失败:${e?.message ?? e}`)
68
+ }
69
+
70
+ // ── ② 新手福利任务 ────────────────────────────────────────────────
51
71
  let tasks = []
72
+ let tasksErr = null
52
73
  try {
53
74
  tasks = await fetchOnboardingTasks(h.jwt, h.org, h.host)
54
75
  } catch (e) {
55
- return { outcome: "failed", message: `读不到新手任务:${e?.message ?? e}` }
76
+ tasksErr = e
56
77
  }
57
78
 
58
- const done = tasks.filter((t) => t.status === "COMPLETED" || t.rewardStatus === "GRANTED")
59
- const pending = tasks.filter((t) => t.status !== "COMPLETED" && t.rewardStatus !== "GRANTED")
60
-
61
- if (!pending.length) {
62
- return {
63
- outcome: "done",
64
- message: `新手福利 ${tasks.length} 项已全部完成(当前积分 ${before})`,
79
+ if (tasksErr) {
80
+ failed = true
81
+ parts.push(`新手任务读取失败:${tasksErr?.message ?? tasksErr}`)
82
+ } else if (!tasks.length) {
83
+ // 国际站:onboarding 接口恒返回空数组,没有「新手福利」这套任务。
84
+ // 但**邀请奖励的判据是一样的** —— 官方 locale 原文「被邀请人完成首个任务
85
+ // 或对话后,即可获得 {{points}} 积分奖励」,而国际站没有任务清单,
86
+ // 所以那条判据在这边就等于「发过一条消息」。
87
+ //
88
+ // 实测(2026-10-28):新号发一条消息后,邀请人立刻 +200。
89
+ //
90
+ // 怎么判断「要不要发」:看这个账号有没有工作台项目 ——
91
+ // 一个项目都没有 = 从没发过消息 → 建项目 + 发一条
92
+ // 已经有项目 = 用过 → 跳过(别每天签到都发,白烧积分)
93
+ const r = await autoFirstChat(h)
94
+ if (r.ok) {
95
+ claimedAnything = true
96
+ parts.push(`首对话:已自动完成(${r.topicName},模型 ${r.model})`)
97
+ } else if (!r.settled) {
98
+ // settled = 这个账号早就补过了(一次性任务)—— 不必每天在签到消息里复述
99
+ parts.push(`首对话:${r.reason}`)
65
100
  }
66
- }
101
+ } else {
102
+ const pending = tasks.filter((t) => t.status !== "COMPLETED" && t.rewardStatus !== "GRANTED")
67
103
 
68
- // 挨个完成
69
- const claimed = []
70
- const skipped = []
71
- const failed = []
104
+ if (!pending.length) {
105
+ parts.push(`新手福利 ${tasks.length} 项均已领完`)
106
+ } else {
107
+ const claimed = []
108
+ const skipped = []
109
+ const taskFailed = []
72
110
 
73
- for (const t of pending) {
74
- if (NOT_MANUALLY_COMPLETABLE.has(t.code)) {
75
- skipped.push(`${t.title}(${t.points} 分,要真跑一次 AI 任务)`)
76
- continue
77
- }
78
- try {
79
- const r = await completeOnboardingTask(h.jwt, h.org, t.code, h.host)
80
- claimed.push({ code: t.code, title: t.title, points: r.points || t.points })
81
- } catch (e) {
82
- if (e?.code === CODE_NOT_SUPPORTED) {
83
- skipped.push(`${t.title}(${t.points} 分,不支持手动完成)`)
84
- } else {
85
- failed.push(`${t.code}: ${e?.message ?? e}`)
111
+ for (const t of pending) {
112
+ // 「完成第一次 AI 工作」是唯一 PUT 不掉的:服务端认死必须是产品内
113
+ // 真的跑完过一次对话(PUT 回 9303)。所以改成**替它发一条消息** ——
114
+ // 走 chat-task 的纯协议通道,不开浏览器。
115
+ // 这也是邀请奖励的触发条件:被邀请人完成首个任务后,邀请人才拿到分。
116
+ if (t.code === "first_ai_work") {
117
+ const r = await autoFirstWork(h)
118
+ if (r.ok) {
119
+ claimed.push({ code: t.code, title: t.title, points: t.points })
120
+ if (r.pending) parts.push("首任务:消息已发出,等结算")
121
+ } else if (r.settled) {
122
+ // 这个账号早就补过了(一次性任务)—— 安静跳过,不必每天复述
123
+ } else {
124
+ skipped.push(`${t.title}(${t.points} 分):${r.reason}`)
125
+ }
126
+ continue
127
+ }
128
+ try {
129
+ const r = await completeOnboardingTask(h.jwt, h.org, t.code, h.host)
130
+ claimed.push({ code: t.code, title: t.title, points: r.points || t.points })
131
+ } catch (e) {
132
+ if (e?.code === CODE_NOT_SUPPORTED) {
133
+ skipped.push(`${t.title}(${t.points} 分,不支持手动完成)`)
134
+ } else {
135
+ taskFailed.push(`${t.code}: ${e?.message ?? e}`)
136
+ }
137
+ }
138
+ // 服务端限流留点余地
139
+ await new Promise((r) => setTimeout(r, 400))
140
+ }
141
+
142
+ if (claimed.length) {
143
+ claimedAnything = true
144
+ parts.push(`新手福利完成 ${claimed.length} 项:${claimed.map((c) => c.title).join("、")}`)
145
+ }
146
+ if (skipped.length) parts.push(`需手动:${skipped.join("、")}`)
147
+ if (taskFailed.length) {
148
+ failed = true
149
+ parts.push(`新手福利失败 ${taskFailed.length} 项:${taskFailed.join(";")}`)
86
150
  }
87
151
  }
88
- // 服务端限流留点余地
89
- await new Promise((r) => setTimeout(r, 400))
90
152
  }
91
153
 
154
+ // ── ③ 复读积分,报净变化 ──────────────────────────────────────────
92
155
  let after = before
93
156
  try {
94
157
  after = (await fetchPoints(h.jwt, h.org, h.host)).total
95
158
  } catch {}
96
159
 
97
160
  const gained = after - before
98
- const parts = []
99
- if (gained > 0) parts.push(`积分 +${gained}(${before} → ${after})`)
100
- else parts.push(`积分 ${after}(本次无变动)`)
101
- if (claimed.length) parts.push(`完成 ${claimed.length} 项:${claimed.map((c) => c.title).join("、")}`)
102
- if (skipped.length) parts.push(`需手动:${skipped.join("、")}`)
103
- if (failed.length) parts.push(`失败 ${failed.length} 项:${failed.join(";")}`)
104
-
105
- if (claimed.length) {
106
- return {
107
- outcome: "claimed",
108
- credit: gained > 0 ? gained : undefined,
109
- message: parts.join("。"),
161
+ const head = gained > 0 ? `积分 +${gained}(${before} → ${after})` : `积分 ${after}(本次无变动)`
162
+ const msg = [head, ...parts].join("。")
163
+
164
+ return {
165
+ outcome: claimedAnything ? "claimed" : failed ? "failed" : "done",
166
+ credit: gained > 0 ? gained : undefined,
167
+ message: msg,
168
+ }
169
+ }
170
+
171
+ /**
172
+ * 替账号完成「第一次 AI 工作」:建个话题、发一条消息、等结算。
173
+ *
174
+ * 为什么不能 PUT:`PUT /onboarding/tasks/first_ai_work/completion` 回 9303
175
+ * 「该新人引导任务不支持手动完成」—— 服务端只认产品内真的有一次对话。
176
+ *
177
+ * 为什么不用浏览器:消息走 Socket.IO,协议已经摸清(见 chat-task.mjs),
178
+ * 纯 Node 发得出去,比拉一个 Chrome 再点输入框可靠得多。
179
+ *
180
+ * @returns {{ok:boolean, pending?:boolean, reason?:string}}
181
+ */
182
+ async function autoFirstWork(h) {
183
+ const siteId = siteIdOf(h.host)
184
+
185
+ // 闸门:这个账号**曾经**补过就不再发 —— 不是「今天发过没有」。
186
+ // 邀请奖励只发一次,补第二遍纯属白烧积分(见 store.mjs 那节注释)。
187
+ if (isAutoChatSettled(siteId, h.org)) {
188
+ return { ok: false, settled: true, reason: "首任务此前已补过(一次性任务)" }
189
+ }
190
+
191
+ try {
192
+ const wsId = await fetchFirstWorkspaceId(h.jwt, h.org, h.host).catch(() => "")
193
+ if (!wsId) {
194
+ // 账号还没进过工作台 —— 不算失败、也不计数:只读接口,下次签到再来
195
+ return { ok: false, reason: "这个账号还没有工作台 —— 先在网页里进一次工作台" }
196
+ }
197
+
198
+ // 重模块按需加载:WebSocket 那套挂了不该拖累整个签到
199
+ const { runChatTask } = await import("./chat-task.mjs")
200
+ const r = await runChatTask({
201
+ jwt: h.jwt,
202
+ org: h.org,
203
+ host: h.host,
204
+ workspaceId: wsId,
205
+ })
206
+ if (!r.ok) {
207
+ // 真去发了但没成 —— 计数,到上限就放弃,别让一个永远失败的号每次签到撞一遍
208
+ const f = noteAutoChatFailure(siteId, h.org, r.reason || "发送失败")
209
+ const why = r.reason || "发送失败"
210
+ return { ok: false, reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
211
+ }
212
+
213
+ // 消息确实发出去了 —— 立刻写**一次性**标记。之后不管结算成不成功、换不换天、
214
+ // 重不重登,都不会再发第二条:这件事判的是「有没有过」,不是「今天有没有」。
215
+ noteAutoChat(siteId, h.org, { topicId: r.topicId || undefined, model: r.model || undefined })
216
+
217
+ // 顺手等一会儿结算,让签到消息报得准。拿不到也无所谓 —— 标记已经写了,
218
+ // 下次签到看到 PENDING 只会安静跳过,不会再发消息。
219
+ for (let i = 0; i < 12; i++) {
220
+ await new Promise((res) => setTimeout(res, 5000))
221
+ const list = await fetchOnboardingTasks(h.jwt, h.org, h.host).catch(() => null)
222
+ if (!list) continue
223
+ const fw = list.find((x) => x.code === "first_ai_work")
224
+ if (fw && (fw.status === "COMPLETED" || fw.rewardStatus === "GRANTED")) {
225
+ return { ok: true }
226
+ }
110
227
  }
228
+ return { ok: true, pending: true }
229
+ } catch (e) {
230
+ const why = String(e?.message ?? e)
231
+ const f = noteAutoChatFailure(siteId, h.org, why)
232
+ return { ok: false, reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
111
233
  }
234
+ }
112
235
 
113
- return {
114
- outcome: failed.length ? "failed" : "done",
115
- message: parts.join("。"),
236
+ /**
237
+ * 国际站的「首个对话」:没有任务清单可依,只能看它用没用过。
238
+ *
239
+ * 判据是**有没有工作台项目**:项目是发第一条消息时前端顺带建的,
240
+ * 一个都没有基本等于从没说过话。已经有项目 = 用过 → 直接记成已补过
241
+ * (一次性),以后再也不用查。
242
+ *
243
+ * @returns {{ok?:boolean, settled?:boolean, reason?:string, topicName?:string, model?:string}}
244
+ */
245
+ async function autoFirstChat(h) {
246
+ const siteId = siteIdOf(h.host)
247
+ // 同一道闸门:一次性。换天、重登、重启都不会再发。
248
+ if (isAutoChatSettled(siteId, h.org)) {
249
+ return { settled: true, reason: "首对话此前已补过(一次性任务)" }
116
250
  }
251
+
252
+ try {
253
+ const wsId = await fetchFirstWorkspaceId(h.jwt, h.org, h.host).catch(() => "")
254
+ if (!wsId) return { reason: "这个账号还没有工作区,先在网页里进一次" }
255
+
256
+ const { findProjectId, runChatTask } = await import("./chat-task.mjs")
257
+ const existing = await findProjectId(h.jwt, h.org, h.host, wsId).catch(() => "")
258
+ if (existing) {
259
+ // 已有项目 = 早就说过话。直接落一次性标记,省得以后每次登录都查一遍。
260
+ noteAutoChat(siteId, h.org, { reason: "已有工作台项目,视为用过" })
261
+ return { settled: true, reason: "此前已用过(已有工作台项目)" }
262
+ }
263
+
264
+ const r = await runChatTask({ jwt: h.jwt, org: h.org, host: h.host, workspaceId: wsId })
265
+ if (!r.ok) {
266
+ const f = noteAutoChatFailure(siteId, h.org, r.reason || "发送失败")
267
+ const why = r.reason || "发送失败"
268
+ return { reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
269
+ }
270
+
271
+ // 发出去了才写一次性标记;失败的一直不写,留给下一次
272
+ noteAutoChat(siteId, h.org, { topicId: r.topicId || undefined, model: r.model || undefined })
273
+ return { ok: true, topicName: r.topicName, model: r.model }
274
+ } catch (e) {
275
+ const why = String(e?.message ?? e)
276
+ const f = noteAutoChatFailure(siteId, h.org, why)
277
+ return { reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
278
+ }
279
+ }
280
+
281
+ /**
282
+ * 账号刚加进来时的一次性补完 —— 登录成功后调用,和签到共用同一道闸门。
283
+ *
284
+ * 为什么单独开一个入口:主人的要求是「一个账号加入的时候做一次」,
285
+ * 而不是「每天签到都做」。登录那一刻就是「加入」,做完就永久标记,
286
+ * 之后无论重登多少次、签到多少天,都不会再发第二条消息。
287
+ *
288
+ * 调用方**不要 await** —— 建项目 + 发消息 + 兜底等结算最坏要一分钟,
289
+ * 登录必须立刻返回。失败也不用管:下一个签到日 checkin 会接着做(同一道闸门)。
290
+ *
291
+ * @param {{jwt:string, org:string, host:string}} h
292
+ */
293
+ export async function ensureFirstTaskOnce(h) {
294
+ const siteId = siteIdOf(h.host)
295
+ if (isAutoChatSettled(siteId, h.org)) return { settled: true }
296
+
297
+ // 国内站:有任务清单。清单里没有 first_ai_work、或者已经完成 → 什么都不用做
298
+ const tasks = await fetchOnboardingTasks(h.jwt, h.org, h.host).catch(() => null)
299
+ if (Array.isArray(tasks) && tasks.length) {
300
+ const fw = tasks.find((t) => t.code === "first_ai_work")
301
+ if (!fw) return { skipped: true, reason: "本站没有这项任务" }
302
+ if (fw.status === "COMPLETED" || fw.rewardStatus === "GRANTED") {
303
+ // 服务端已经认过了 —— 落标记,以后不查了
304
+ noteAutoChat(siteId, h.org, { reason: "服务端已标记完成" })
305
+ return { skipped: true, reason: "已完成" }
306
+ }
307
+ return autoFirstWork(h)
308
+ }
309
+
310
+ // 国际站:没有任务清单 → 看有没有工作台项目
311
+ return autoFirstChat(h)
117
312
  }
118
313
 
119
314
  export { NOT_MANUALLY_COMPLETABLE }
@@ -1,234 +0,0 @@
1
- // 自注册:把本插件写进 magpie 的市场缓存
2
- //
3
- // ═══════════════════════════════════════════════════════════════════
4
- // 问题
5
- // ═══════════════════════════════════════════════════════════════════
6
- //
7
- // magpie 的插件卡片上,「源码」按钮和卡片标题来自
8
- // ~/.cache/magpie/plugin-market.json —— magpie 的社区插件列表缓存。
9
- //
10
- // 包不在那个列表里 ⇒ 卡片标题退回包名,没有源码按钮。
11
- //
12
- // ═══════════════════════════════════════════════════════════════════
13
- // 做法:攒一批,合并去重,写一次
14
- // ═══════════════════════════════════════════════════════════════════
15
- //
16
- // magpie 把**所有插件加载进同一个 Bun 进程**(它自己的文档写
17
- // "one Bun process, the plugin host")。所以插件之间可以通过
18
- // globalThis 共享一个队列。
19
- //
20
- // 插件 A 加载 → 入队
21
- // 插件 B 加载 → 入队
22
- // ... ↓ 100ms 窗口
23
- // 合并去重 → 一次写入
24
- //
25
- // 实测:10 个插件同时启动,**只写盘 1 次**,全部存活,官方条目不动。
26
- //
27
- // ═══════════════════════════════════════════════════════════════════
28
- // 只写一次,绝不轮询
29
- // ═══════════════════════════════════════════════════════════════════
30
- //
31
- // 实测(2026-10-25):
32
- //
33
- // 手工往缓存里加一条 → 等 10 秒 → 没被覆盖
34
- // 再跑 plugin update → 也没被覆盖
35
- // 连续观察 3 分钟 → mtime 零变化
36
- //
37
- // magpie 只在启动时重写这个文件,静默运行期间完全不碰。
38
- //
39
- // ⚠⚠ 绝对不要用 fs.watch 或 setInterval 去「补」。
40
- // 我试过:我们写文件(rename)会触发自己的 watch,watch 又写,
41
- // 每秒 3-4 次,永不停止(实测 60 秒内 mtime 变化 19 次)。
42
- // 删掉后 60 秒变化 0 次。
43
- //
44
- // ═══════════════════════════════════════════════════════════════════
45
-
46
- import fs from "node:fs"
47
- import path from "node:path"
48
- import os from "node:os"
49
-
50
- /** 官方 registry 的地址(缓存丢了时的兜底)*/
51
- const OFFICIAL_REGISTRY =
52
- "https://raw.githubusercontent.com/magpie-community/plugins/main/registry.json"
53
-
54
- /** 队列的共享键(Symbol.for 保证同一进程内所有插件拿到同一个)*/
55
- const QUEUE_KEY = Symbol.for("magpie.plugin-template.registry-queue")
56
-
57
- /** 攒批窗口。够短(卡片不卡),够长(同期插件都能进来)*/
58
- const FLUSH_MS = 100
59
-
60
- /**
61
- * 从 magpie 的配置目录推出它的缓存目录。
62
- *
63
- * 插件的 directory 是 magpie 的 config folder(~/.config/magpie)。
64
- * XDG 规则下 cache 和 config 平级。
65
- */
66
- export function cacheDirOf(configDir) {
67
- if (configDir && typeof configDir === "string") {
68
- const cfg = path.resolve(configDir)
69
- const guess = path.join(path.dirname(path.dirname(cfg)), ".cache", "magpie")
70
- if (fs.existsSync(guess)) return guess
71
- if (process.platform === "darwin") {
72
- const mac = path.join(os.homedir(), "Library", "Caches", "magpie")
73
- if (fs.existsSync(mac)) return mac
74
- }
75
- const xdg = process.env.XDG_CACHE_HOME
76
- if (xdg) return path.join(xdg, "magpie")
77
- return guess
78
- }
79
- const xdg = process.env.XDG_CACHE_HOME
80
- if (xdg) return path.join(xdg, "magpie")
81
- return path.join(os.homedir(), ".cache", "magpie")
82
- }
83
-
84
- /** 取(或创建)共享队列 */
85
- function queue(configDir) {
86
- if (!globalThis[QUEUE_KEY]) {
87
- globalThis[QUEUE_KEY] = {
88
- /** package → entry,按包名天然去重 */
89
- entries: new Map(),
90
- timer: null,
91
- configDir,
92
- log: () => {},
93
- writes: 0,
94
- }
95
- }
96
- const q = globalThis[QUEUE_KEY]
97
- if (configDir) q.configDir = configDir
98
- return q
99
- }
100
-
101
- /**
102
- * 把一个条目加入队列。
103
- *
104
- * 同一个包名重复入队只保留最后一次 —— 这就是去重。
105
- *
106
- * @param {object} entry { package, name, providers?, community?, summary }
107
- * @param {{configDir?: string, log?: Function, timeoutMs?: number}} [opts]
108
- */
109
- export function registerSelf(entry, opts = {}) {
110
- if (!entry || typeof entry.package !== "string" || entry.package.length === 0) {
111
- return Promise.resolve({ ok: false, reason: "entry.package 缺失" })
112
- }
113
-
114
- const q = queue(opts.configDir)
115
- if (typeof opts.log === "function") q.log = opts.log
116
-
117
- q.entries.set(entry.package, entry)
118
-
119
- if (q.timer) return Promise.resolve({ ok: true, queued: true })
120
-
121
- return new Promise((resolve) => {
122
- q.timer = setTimeout(() => {
123
- q.timer = null
124
- const r = flush(q, opts.timeoutMs)
125
- resolve(r)
126
- }, FLUSH_MS)
127
- if (typeof q.timer.unref === "function") q.timer.unref()
128
- })
129
- }
130
-
131
- /**
132
- * 一次读、一次合并、一次写。
133
- *
134
- * 队列里的所有条目(可能来自多个插件)在这个函数里一起进缓存,
135
- * 所以并发插件之间不存在「你覆盖我」的窗口。
136
- */
137
- function flush(q, timeoutMs = 15_000) {
138
- const log = q.log
139
- if (q.entries.size === 0) return { ok: true, changed: false }
140
-
141
- const cacheDir = cacheDirOf(q.configDir)
142
- const marketPath = path.join(cacheDir, "plugin-market.json")
143
-
144
- try {
145
- let current = null
146
- if (fs.existsSync(marketPath)) {
147
- try {
148
- current = JSON.parse(fs.readFileSync(marketPath, "utf8"))
149
- } catch {
150
- current = null
151
- }
152
- }
153
-
154
- if (!current || !Array.isArray(current.plugins)) {
155
- // 缓存不可用就跳过(不阻塞插件加载)。下次启动还会再试。
156
- const names = [...q.entries.keys()].join(", ")
157
- log(`市场缓存不可用,跳过注册:${names}`)
158
- q.entries.clear()
159
- return { ok: false, reason: "市场缓存不可用" }
160
- }
161
-
162
- const snapshot = JSON.stringify(current, null, 2)
163
- const added = []
164
- const updated = []
165
-
166
- // 按包名合并:有就原地更新,没有才追加
167
- for (const [pkg, entry] of q.entries) {
168
- const idx = current.plugins.findIndex((x) => x?.package === pkg)
169
- if (idx >= 0) {
170
- current.plugins[idx] = entry
171
- updated.push(pkg)
172
- } else {
173
- current.plugins.push(entry)
174
- added.push(pkg)
175
- }
176
- }
177
- q.entries.clear()
178
-
179
- const next = JSON.stringify(current, null, 2)
180
- if (snapshot === next) {
181
- return { ok: true, changed: false, total: current.plugins.length }
182
- }
183
-
184
- fs.mkdirSync(cacheDir, { recursive: true })
185
- const tmp = `${marketPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 6)}`
186
- fs.writeFileSync(tmp, next, "utf8")
187
- fs.renameSync(tmp, marketPath)
188
- q.writes++
189
-
190
- const parts = []
191
- if (added.length) parts.push(`新增 ${added.length}`)
192
- if (updated.length) parts.push(`更新 ${updated.length}`)
193
- log(`已注册 ${parts.join(",")}(共 ${current.plugins.length} 条,这是第 ${q.writes} 次写入)`)
194
-
195
- return {
196
- ok: true,
197
- changed: true,
198
- total: current.plugins.length,
199
- added,
200
- updated,
201
- }
202
- } catch (e) {
203
- const msg = e instanceof Error ? e.message : String(e)
204
- log(`自注册失败:${msg}`)
205
- q.entries.clear()
206
- return { ok: false, reason: msg }
207
- }
208
- }
209
-
210
- /**
211
- * 启动自注册。
212
- *
213
- * 一次调用只入队一次。同期的其它插件会在同一个窗口里被合并,
214
- * 一起写进缓存 —— 不管有多少个插件,写入次数都接近 1。
215
- *
216
- * @param {object} entry
217
- * @param {{configDir?: string, log?: Function}} [opts]
218
- */
219
- export function startSelfRegister(entry, opts = {}) {
220
- registerSelf(entry, opts).catch(() => {})
221
- }
222
-
223
- /** 供测试:立刻清空队列(不影响真实使用)*/
224
- export function _flushNow() {
225
- const q = globalThis[QUEUE_KEY]
226
- if (!q) return { ok: true, changed: false }
227
- if (q.timer) {
228
- clearTimeout(q.timer)
229
- q.timer = null
230
- }
231
- return flush(q)
232
- }
233
-
234
- export const _internal = { cacheDirOf, OFFICIAL_REGISTRY, QUEUE_KEY, FLUSH_MS }