magicrew-auth 0.3.31 → 0.3.33
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/AGENTS.md +3 -0
- package/CHANGELOG.md +109 -0
- package/README.md +1 -0
- package/index.mjs +32 -3
- package/package.json +10 -2
- package/page/app.js +243 -0
- package/page/index.html +53 -0
- package/src/browser-login.mjs +99 -22
- package/src/hooks/loader.mjs +40 -2
- package/src/hooks/methods.mjs +97 -10
- package/src/hooks/usage.mjs +12 -0
- package/src/store.mjs +251 -5
package/src/store.mjs
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
//
|
|
8
8
|
// ⚠ 这不是「状态管理」,是缓存:删掉全部,插件照样能跑(只是要重新登录一次)。
|
|
9
9
|
|
|
10
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs"
|
|
11
11
|
import { homedir } from "node:os"
|
|
12
12
|
import { join, dirname } from "node:path"
|
|
13
|
+
import { fileURLToPath } from "node:url"
|
|
13
14
|
import { fetchOwnInviteCode } from "./api.mjs"
|
|
14
15
|
|
|
15
16
|
function cacheDir() {
|
|
@@ -113,9 +114,251 @@ function writeInvite(siteId, code) {
|
|
|
113
114
|
return c
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
/**
|
|
117
|
-
function inviteToUse(siteId) {
|
|
118
|
-
|
|
117
|
+
/** 这次登录该带的邀请码:锁定 → 策略 → 记录 → 作者码 */
|
|
118
|
+
function inviteToUse(siteId, locked = true) {
|
|
119
|
+
const live = liveOrgs(siteId)
|
|
120
|
+
// 账号文件读不到 / 一个号都没有时按「不校验」处理,行为与以前一致
|
|
121
|
+
const known = live !== null && live.size > 0
|
|
122
|
+
if (locked) {
|
|
123
|
+
const pol = readInvitePolicy(siteId)
|
|
124
|
+
if ((pol.mode === "fixed" || pol.mode === "custom") && pol.code) {
|
|
125
|
+
// fixed 挂在某个账号上 —— 账号删了码就别再带(新号会注册落空)
|
|
126
|
+
if (!(known && pol.mode === "fixed" && pol.org && !live.has(pol.org))) return pol.code
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const rolling = readInvite(siteId)
|
|
130
|
+
if (rolling) {
|
|
131
|
+
if (!known) return rolling
|
|
132
|
+
// 滚动码也可能属于已删除的账号:按码反查归属,归属没了就换作者码兜底
|
|
133
|
+
const codes = readAccountCodes(siteId)
|
|
134
|
+
const owner = Object.keys(codes).find((o) => codes[o]?.code === rolling)
|
|
135
|
+
if (!owner || live.has(owner)) return rolling
|
|
136
|
+
}
|
|
137
|
+
return AUTHOR_INVITE[siteId] || ""
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* magpie 账号文件里这个站点还登录着的 org(只读 plugin-auth.json)。
|
|
142
|
+
* 返回 null 表示读不到 —— 调用方应按「不校验」处理,而不是当空集。
|
|
143
|
+
*/
|
|
144
|
+
export function liveOrgs(siteId) {
|
|
145
|
+
try {
|
|
146
|
+
const raw = JSON.parse(readFileSync(join(homedir(), ".config", "magpie", "plugin-auth.json"), "utf8"))
|
|
147
|
+
const out = new Set()
|
|
148
|
+
for (const [k, v] of Object.entries(raw || {})) {
|
|
149
|
+
if (k === siteId || k.startsWith(siteId + "#")) {
|
|
150
|
+
const org = String(v?.org || "").trim()
|
|
151
|
+
if (org) out.add(org)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out
|
|
155
|
+
} catch {
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 作者自己的码(「我的」那一项) */
|
|
161
|
+
export function authorInvite(siteId) {
|
|
162
|
+
return AUTHOR_INVITE[siteId] || ""
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ═══ 邀请上级策略 ═══════════════════════════════════════════════════
|
|
166
|
+
//
|
|
167
|
+
// 三种模式,登录时选,落盘后一直生效:
|
|
168
|
+
//
|
|
169
|
+
// auto (默认)用「最后一个登录 / 签到的号」的码 —— 老行为,会漂移
|
|
170
|
+
// fixed 固定挂到某个已登录账号(存 org + code)
|
|
171
|
+
// custom 任意码,用户自己填
|
|
172
|
+
//
|
|
173
|
+
// 为什么要这个:`rollInvite` 会在每次登录、每天签到把
|
|
174
|
+
// magicrew-invite-<site>.json 覆盖成最后那个号的码。奖励虽然不出圈,
|
|
175
|
+
// 但「新号挂谁」就变成随机的了 —— 用户要的是可预期。
|
|
176
|
+
//
|
|
177
|
+
// ⚠ fixed 存的是**解析好的码**,不是「登录时再去问那个号要」:
|
|
178
|
+
// 要问就得多一条带那个账号 jwt 的请求,而签到 / 登录路径上都没有它。
|
|
179
|
+
// 代价是对方换码后要重新选一次(码实测长期不变)。
|
|
180
|
+
|
|
181
|
+
function invitePolicyPath(siteId) {
|
|
182
|
+
return join(cacheDir(), `magicrew-invite-policy-${siteId}.json`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function readInvitePolicy(siteId) {
|
|
186
|
+
try {
|
|
187
|
+
const v = JSON.parse(readFileSync(invitePolicyPath(siteId), "utf8"))
|
|
188
|
+
const mode = ["auto", "fixed", "custom"].includes(v?.mode) ? v.mode : "auto"
|
|
189
|
+
const org = String(v?.org || "").trim()
|
|
190
|
+
return {
|
|
191
|
+
mode,
|
|
192
|
+
code: String(v?.code || "").trim(),
|
|
193
|
+
// 旧版把作者码存成 org:"author";作者不是账号,按「无 org」处理
|
|
194
|
+
org: org === "author" ? "" : org,
|
|
195
|
+
accountId: String(v?.accountId || "").trim(),
|
|
196
|
+
at: String(v?.at || ""),
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
return { mode: "auto", code: "", org: "", accountId: "", at: "" }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function writeInvitePolicy(siteId, policy) {
|
|
204
|
+
try {
|
|
205
|
+
const f = invitePolicyPath(siteId)
|
|
206
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
207
|
+
const mode = ["auto", "fixed", "custom"].includes(policy?.mode) ? policy.mode : "auto"
|
|
208
|
+
writeFileSync(
|
|
209
|
+
f,
|
|
210
|
+
JSON.stringify({
|
|
211
|
+
mode,
|
|
212
|
+
code: String(policy?.code || "").trim(),
|
|
213
|
+
org: String(policy?.org || "").trim() === "author" ? "" : String(policy?.org || "").trim(),
|
|
214
|
+
accountId: String(policy?.accountId || "").trim(),
|
|
215
|
+
at: new Date().toISOString(),
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
syncInvitePage() // 页面上的「当前」跟着刷新
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 清掉策略 —— 下次登录会重新问一次(「改回去」的唯一入口) */
|
|
223
|
+
export function clearInvitePolicy(siteId) {
|
|
224
|
+
try {
|
|
225
|
+
unlinkSync(invitePolicyPath(siteId))
|
|
226
|
+
} catch {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ═══ 见过哪些账号的码 ═══════════════════════════════════════════════
|
|
230
|
+
//
|
|
231
|
+
// `rollInvite` 每天签到会给**每个**账号跑一次,顺手把它的码记下来 ——
|
|
232
|
+
// 于是登录时那个「挂在谁名下」的下拉框不用发网络请求就能填满。
|
|
233
|
+
// (prompts 的 options 是同步生成的,等不了 API。)
|
|
234
|
+
|
|
235
|
+
function inviteCodesPath(siteId) {
|
|
236
|
+
return join(cacheDir(), `magicrew-invite-codes-${siteId}.json`)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function readAccountCodes(siteId) {
|
|
240
|
+
try {
|
|
241
|
+
const v = JSON.parse(readFileSync(inviteCodesPath(siteId), "utf8"))
|
|
242
|
+
return v && typeof v === "object" ? v : {}
|
|
243
|
+
} catch {
|
|
244
|
+
return {}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function noteAccountCode(siteId, org, code, accountId) {
|
|
249
|
+
const o = String(org || "").trim()
|
|
250
|
+
const cd = String(code || "").trim()
|
|
251
|
+
if (!o || !cd) return
|
|
252
|
+
try {
|
|
253
|
+
const all = readAccountCodes(siteId)
|
|
254
|
+
all[o] = { code: cd, accountId: String(accountId || ""), at: new Date().toISOString() }
|
|
255
|
+
const f = inviteCodesPath(siteId)
|
|
256
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
257
|
+
writeFileSync(f, JSON.stringify(all))
|
|
258
|
+
syncInvitePage() // 页面清单跟着刷新
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 把「有哪些账号、各自的邀请码、当前策略」写进 page/codes.js。
|
|
264
|
+
*
|
|
265
|
+
* 插件页面是沙箱:没有 fetch、读不到磁盘,也读不到账号(magpie 只给它 usage / providers)。
|
|
266
|
+
* 所以清单靠这里预先生成一份静态脚本,页面 <script src="codes.js"> 直接读 —— 与
|
|
267
|
+
* agent-purge 的 gen-personas.mjs 是同一个套路。
|
|
268
|
+
*/
|
|
269
|
+
/**
|
|
270
|
+
* org → 便于辨认的名字(打码手机号 / 邮箱),从 magpie 自己的账号文件**只读**。
|
|
271
|
+
* 页面要显示「178****697(IV…)」,而 rollInvite 只拿得到 org,所以在这里补。
|
|
272
|
+
*/
|
|
273
|
+
function readAccountLabels() {
|
|
274
|
+
const out = {}
|
|
275
|
+
try {
|
|
276
|
+
const raw = JSON.parse(readFileSync(join(homedir(), ".config", "magpie", "plugin-auth.json"), "utf8"))
|
|
277
|
+
for (const v of Object.values(raw || {})) {
|
|
278
|
+
const org = String(v?.org || "")
|
|
279
|
+
const id = String(v?.accountId || "")
|
|
280
|
+
if (org && id && !out[org]) out[org] = id
|
|
281
|
+
}
|
|
282
|
+
} catch {}
|
|
283
|
+
return out
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 删除账号后的确定性清理:码缓存去掉死号;滚动码若属死号一并清掉
|
|
288
|
+
* (不清掉的话,缓存一清 inviteToUse 就查不到归属,反判成「归属不明」保留)。
|
|
289
|
+
*
|
|
290
|
+
* ⚠ 新号竞态保护(2026-10-29 实测复现):注册登录时 rollInvite 先记码,
|
|
291
|
+
* magpie 把新号写进 plugin-auth.json 在**之后** —— 此刻 liveOrgs 看不到新号,
|
|
292
|
+
* 直接清理会把刚记的码当死号删掉,页面要等下次登录/签到才有它。
|
|
293
|
+
* 所以 **10 分钟内记录的条目不参与清理**;这窗口内 inviteToUse 的死号门
|
|
294
|
+
* (owner 死→回落)仍然兜底。
|
|
295
|
+
* 账号文件读不到(liveOrgs === null)时不动,避免误清。
|
|
296
|
+
*/
|
|
297
|
+
const FRESH_ACCOUNT_MS = 10 * 60_000
|
|
298
|
+
|
|
299
|
+
export function pruneDeadAccounts(siteId) {
|
|
300
|
+
const live = liveOrgs(siteId)
|
|
301
|
+
if (!live || !live.size) return null
|
|
302
|
+
const codes = readAccountCodes(siteId)
|
|
303
|
+
const fresh = (v) => {
|
|
304
|
+
try {
|
|
305
|
+
return Date.now() - Date.parse(v?.at || "") < FRESH_ACCOUNT_MS
|
|
306
|
+
} catch {
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const dead = Object.keys(codes).filter((org) => !live.has(org) && !fresh(codes[org]))
|
|
311
|
+
if (!dead.length) return { dead: [], rollingCleared: false }
|
|
312
|
+
let rollingCleared = false
|
|
313
|
+
const rollingNow = readInvite(siteId)
|
|
314
|
+
if (rollingNow && dead.some((org) => codes[org]?.code === rollingNow)) {
|
|
315
|
+
try {
|
|
316
|
+
unlinkSync(invitePath(siteId))
|
|
317
|
+
rollingCleared = true
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
320
|
+
const kept = { ...codes }
|
|
321
|
+
for (const org of dead) delete kept[org]
|
|
322
|
+
try {
|
|
323
|
+
const f = inviteCodesPath(siteId)
|
|
324
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
325
|
+
writeFileSync(f, JSON.stringify(kept))
|
|
326
|
+
} catch {}
|
|
327
|
+
return { dead, rollingCleared }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function syncInvitePage({ root: target } = {}) {
|
|
331
|
+
try {
|
|
332
|
+
const root = target || dirname(dirname(fileURLToPath(import.meta.url)))
|
|
333
|
+
// 仓库里的测试/开发不生成(否则 page/codes.js 会脏工作区);发布包装有 .git 才写。
|
|
334
|
+
// 显式传 root(测试)时不受这个保护限制。
|
|
335
|
+
if (!target && (process.env.MAGICREW_NO_PAGE_SYNC === "1" || existsSync(join(root, ".git")))) return
|
|
336
|
+
const labels = readAccountLabels()
|
|
337
|
+
const sites = {}
|
|
338
|
+
for (const site of ["magicrew", "magicrew-intl"]) {
|
|
339
|
+
const live = liveOrgs(site)
|
|
340
|
+
pruneDeadAccounts(site)
|
|
341
|
+
let codes = readAccountCodes(site)
|
|
342
|
+
for (const [org, v] of Object.entries(codes)) {
|
|
343
|
+
if (v && !v.accountId && labels[org]) v.accountId = labels[org]
|
|
344
|
+
}
|
|
345
|
+
const pol = readInvitePolicy(site)
|
|
346
|
+
const rolling = readInvite(site)
|
|
347
|
+
const ownerOf = (code) => Object.keys(codes).find((o) => codes[o]?.code === code) || ""
|
|
348
|
+
sites[site] = {
|
|
349
|
+
codes,
|
|
350
|
+
policy: pol,
|
|
351
|
+
// 当前选择指向已删除的账号 —— 页面据此提示,登录侧已自动回退
|
|
352
|
+
policyStale: Boolean(live && live.size && pol.mode === "fixed" && pol.org && !live.has(pol.org)),
|
|
353
|
+
rolling,
|
|
354
|
+
rollingStale: Boolean(live && live.size && rolling && ownerOf(rolling) && !live.has(ownerOf(rolling))),
|
|
355
|
+
author: AUTHOR_INVITE[site] || "",
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const f = join(root, "page", "codes.js")
|
|
359
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
360
|
+
writeFileSync(f, "window.MAGICREW_CODES = " + JSON.stringify({ at: new Date().toISOString(), sites }) + ";\n")
|
|
361
|
+
} catch {}
|
|
119
362
|
}
|
|
120
363
|
|
|
121
364
|
/** 滚动请求最多等这么久 —— 登录流程不能被它拖死 */
|
|
@@ -137,7 +380,10 @@ async function rollInvite(siteId, jwt, org, host) {
|
|
|
137
380
|
fetchOwnInviteCode(jwt, org, host),
|
|
138
381
|
new Promise((_, reject) => setTimeout(() => reject(new Error("超时")), INVITE_TIMEOUT_MS)),
|
|
139
382
|
])
|
|
140
|
-
|
|
383
|
+
const code = writeInvite(siteId, own.code)
|
|
384
|
+
// 顺手记下「哪个账号的码是多少」—— 登录时的下拉框靠它填,不用发请求
|
|
385
|
+
noteAccountCode(siteId, org, code)
|
|
386
|
+
return code
|
|
141
387
|
} catch (e) {
|
|
142
388
|
if (process.env.MAGICREW_DEBUG) {
|
|
143
389
|
console.error(`[magicrew] 滚动邀请码失败(${siteId}):${e?.message ?? e}`)
|