magicrew-auth 0.0.0-stage → 0.3.6
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/CHANGELOG.md +176 -0
- package/LICENSE +21 -0
- package/NOTICE.md +33 -0
- package/README.md +200 -3
- package/docs/internals.md +802 -0
- package/docs/npm-publish.md +77 -0
- package/index.mjs +99 -0
- package/package.json +52 -4
- package/src/api.mjs +409 -0
- package/src/browser-login.mjs +408 -0
- package/src/clipboard.mjs +108 -0
- package/src/context.mjs +60 -0
- package/src/hooks/checkin.mjs +21 -0
- package/src/hooks/config.mjs +25 -0
- package/src/hooks/headers.mjs +14 -0
- package/src/hooks/loader.mjs +93 -0
- package/src/hooks/methods.mjs +191 -0
- package/src/hooks/models.mjs +37 -0
- package/src/hooks/refresh.mjs +29 -0
- package/src/hooks/usage.mjs +40 -0
- package/src/icons.mjs +24 -0
- package/src/key-store.mjs +188 -0
- package/src/keys.mjs +109 -0
- package/src/model-context.mjs +100 -0
- package/src/self-register.mjs +234 -0
- package/src/store.mjs +183 -0
- package/src/tasks.mjs +119 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// 每个账号一把活的 key —— A/B 双槽
|
|
2
|
+
//
|
|
3
|
+
// ═══ 两条不能破的线 ═══════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// ① **按账号隔离**:同一个站点可以登多个账号(magpie 里就是 `magicrew` /
|
|
6
|
+
// `magicrew#1` / …)。key 必须一个账号一份,否则第二个账号登录时
|
|
7
|
+
// 会把第一个账号的 key 覆盖掉,第一个账号从此 403。
|
|
8
|
+
// 账户标识用 `org`(组织码,登录后固定不变),拿不到才退回 accountId。
|
|
9
|
+
//
|
|
10
|
+
// ② **每个账号至少有一把活的**:一个账号存两槽(a / b)。
|
|
11
|
+
// · 续期时把新的签到**另一个槽**再切过去,旧的留着 —— 中途出错还能切回来
|
|
12
|
+
// · 当前这把被上游拒了(403)就切到另一槽
|
|
13
|
+
// · 两槽都空时从 magpie 存的凭据接管一次
|
|
14
|
+
//
|
|
15
|
+
// 实测(2026-10-27):同一账号的两把 `mgw_` key 可以同时有效,所以双槽是安全的;
|
|
16
|
+
// 一把 key 也能并发(6 个并发请求全成功),不需要"闲置池"。
|
|
17
|
+
//
|
|
18
|
+
// ⚠ 全插件只有这个文件是 key 的来源。magpie 的 `auth.refresh` 和 loader 都走
|
|
19
|
+
// src/keys.mjs,不各自签发 —— 否则本地会分叉,说不清手里那把是不是服务端认的。
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from "node:fs"
|
|
22
|
+
import { homedir } from "node:os"
|
|
23
|
+
import { join, dirname } from "node:path"
|
|
24
|
+
|
|
25
|
+
/** 提前多久续期。key 只活 2 小时,10 分钟够用 */
|
|
26
|
+
const LEAD_MS = 10 * 60_000
|
|
27
|
+
|
|
28
|
+
/** 只接受看起来像真的 key —— 测试/占位值不许落盘 */
|
|
29
|
+
const REAL_KEY = /^mgw_[A-Za-z0-9_-]{20,}$/
|
|
30
|
+
|
|
31
|
+
/** 账号标识净化:只留安全字符,空值给 default */
|
|
32
|
+
export function safeAccount(account) {
|
|
33
|
+
const s = String(account || "").replace(/[^A-Za-z0-9_-]/g, "")
|
|
34
|
+
return s || "default"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 一个账号一份文件 */
|
|
38
|
+
export function keyPath(siteId, account) {
|
|
39
|
+
return join(homedir(), ".cache", "magpie", `magicrew-key-${siteId}__${safeAccount(account)}.json`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function blank() {
|
|
43
|
+
return { current: "a", refreshToken: "", keys: { a: null, b: null }, at: "" }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalize(v) {
|
|
47
|
+
// 旧格式(单槽):{ apiKey, refreshToken, expiresAt, at } → 迁移到 a 槽
|
|
48
|
+
if (v && typeof v === "object" && typeof v.apiKey === "string" && !v.keys) {
|
|
49
|
+
return {
|
|
50
|
+
current: "a",
|
|
51
|
+
refreshToken: String(v.refreshToken || ""),
|
|
52
|
+
keys: {
|
|
53
|
+
a: REAL_KEY.test(v.apiKey) ? { apiKey: v.apiKey, expiresAt: Number(v.expiresAt) || 0 } : null,
|
|
54
|
+
b: null,
|
|
55
|
+
},
|
|
56
|
+
at: String(v.at || ""),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const s = v && typeof v === "object" ? v : {}
|
|
60
|
+
const keys = s.keys && typeof s.keys === "object" ? s.keys : {}
|
|
61
|
+
const clean = (k) =>
|
|
62
|
+
k && typeof k.apiKey === "string" && REAL_KEY.test(k.apiKey)
|
|
63
|
+
? { apiKey: k.apiKey, expiresAt: Number(k.expiresAt) || 0 }
|
|
64
|
+
: null
|
|
65
|
+
return {
|
|
66
|
+
current: s.current === "b" ? "b" : "a",
|
|
67
|
+
refreshToken: String(s.refreshToken || ""),
|
|
68
|
+
keys: { a: clean(keys.a), b: clean(keys.b) },
|
|
69
|
+
at: String(s.at || ""),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readStore(siteId, account) {
|
|
74
|
+
try {
|
|
75
|
+
return normalize(JSON.parse(readFileSync(keyPath(siteId, account), "utf8")))
|
|
76
|
+
} catch {
|
|
77
|
+
return blank()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function writeStore(siteId, account, st) {
|
|
82
|
+
const next = normalize(st)
|
|
83
|
+
next.at = new Date().toISOString()
|
|
84
|
+
try {
|
|
85
|
+
const f = keyPath(siteId, account)
|
|
86
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
87
|
+
writeFileSync(f, JSON.stringify(next))
|
|
88
|
+
} catch {}
|
|
89
|
+
return next
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 这个站点下所有账号的 key 文件(排障用) */
|
|
93
|
+
export function listAccounts(siteId) {
|
|
94
|
+
try {
|
|
95
|
+
const dir = join(homedir(), ".cache", "magpie")
|
|
96
|
+
const pfx = `magicrew-key-${siteId}__`
|
|
97
|
+
return readdirSync(dir)
|
|
98
|
+
.filter((f) => f.startsWith(pfx) && f.endsWith(".json"))
|
|
99
|
+
.map((f) => f.slice(pfx.length, -5))
|
|
100
|
+
} catch {
|
|
101
|
+
return []
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 清掉某个账号的 key(重新登录时用) */
|
|
106
|
+
export function dropStore(siteId, account) {
|
|
107
|
+
try {
|
|
108
|
+
unlinkSync(keyPath(siteId, account))
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const otherSlot = (slot) => (slot === "a" ? "b" : "a")
|
|
113
|
+
|
|
114
|
+
/** 这一把还能用吗:没有到期信息时按「能用」处理,由服务端说了算 */
|
|
115
|
+
export function isFresh(k, leadMs = LEAD_MS) {
|
|
116
|
+
if (!k?.apiKey) return false
|
|
117
|
+
if (!k.expiresAt) return true
|
|
118
|
+
return Date.now() + leadMs < k.expiresAt
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function currentKey(st) {
|
|
122
|
+
return st?.keys?.[st.current] ?? null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 两槽里还有活的吗 */
|
|
126
|
+
export function hasLiveKey(st, leadMs = 0) {
|
|
127
|
+
return ["a", "b"].some((s) => isFresh(st?.keys?.[s], leadMs))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 把服务端给的到期时间标准化成毫秒时间戳。
|
|
132
|
+
* 实测两种样子:`"2026-10-27 18:36:26"`(本地时间)和毫秒数字。
|
|
133
|
+
*/
|
|
134
|
+
export function toEpochMs(v) {
|
|
135
|
+
if (typeof v === "number" && v > 1e12) return v
|
|
136
|
+
if (typeof v === "string" && v.trim()) {
|
|
137
|
+
const s = v.trim()
|
|
138
|
+
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/)
|
|
139
|
+
if (m) {
|
|
140
|
+
const [, y, mo, d, h, mi, se] = m.map(Number)
|
|
141
|
+
return new Date(y, mo - 1, d, h, mi, se).getTime()
|
|
142
|
+
}
|
|
143
|
+
const t = Date.parse(s)
|
|
144
|
+
if (!Number.isNaN(t)) return t
|
|
145
|
+
}
|
|
146
|
+
return 0
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 当前这把被上游拒了 → 切到另一槽。被拒的那把标失效,免得下次又用它。
|
|
151
|
+
* @returns 另一槽那把 key,没有可用的就 null
|
|
152
|
+
*/
|
|
153
|
+
export function switchSlot(siteId, account, badApiKey) {
|
|
154
|
+
const st = readStore(siteId, account)
|
|
155
|
+
const keys = { ...st.keys }
|
|
156
|
+
for (const slot of ["a", "b"]) {
|
|
157
|
+
if (keys[slot]?.apiKey === badApiKey) keys[slot] = { ...keys[slot], expiresAt: 0 }
|
|
158
|
+
}
|
|
159
|
+
const alt = otherSlot(st.current)
|
|
160
|
+
const target = keys[alt]?.apiKey ? alt : keys[st.current]?.apiKey ? st.current : null
|
|
161
|
+
if (!target) return null
|
|
162
|
+
writeStore(siteId, account, { ...st, current: target, keys })
|
|
163
|
+
return keys[target]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── 续期串行化 ────────────────────────────────────────────────────────
|
|
167
|
+
//
|
|
168
|
+
// refresh_token 一次性。多个请求同时发现 key 不新鲜会一起续,除第一个外全失败。
|
|
169
|
+
// 按「站点 + 账号」把续期合成一次。
|
|
170
|
+
|
|
171
|
+
const inflight = new Map()
|
|
172
|
+
|
|
173
|
+
export function renewOnce(siteId, account, doRenew) {
|
|
174
|
+
const k = `${siteId}__${safeAccount(account)}`
|
|
175
|
+
const running = inflight.get(k)
|
|
176
|
+
if (running) return running
|
|
177
|
+
const p = (async () => {
|
|
178
|
+
try {
|
|
179
|
+
return await doRenew()
|
|
180
|
+
} catch {
|
|
181
|
+
return null
|
|
182
|
+
} finally {
|
|
183
|
+
inflight.delete(k)
|
|
184
|
+
}
|
|
185
|
+
})()
|
|
186
|
+
inflight.set(k, p)
|
|
187
|
+
return p
|
|
188
|
+
}
|
package/src/keys.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// key 的唯一出口 —— 按账号隔离的 A/B 双槽
|
|
2
|
+
//
|
|
3
|
+
// 登录成功 → issueAndStore() 签一把放进「另一个槽」,切过去
|
|
4
|
+
// loader → ensureKey() 快过期就把新的签到另一个槽,切过去;旧的留着当回退
|
|
5
|
+
// refresh → ensureKey() 同上,magpie 拿到的也是同一把
|
|
6
|
+
//
|
|
7
|
+
// **每个账号一套槽,互不干扰** —— 同站点登两个账号时,第二个人登录不会碰第一个人的 key。
|
|
8
|
+
// 账号标识用 `org`(组织码),拿不到才退回 accountId。
|
|
9
|
+
|
|
10
|
+
import { issueGatewayKey, refreshGatewayKey } from "./api.mjs"
|
|
11
|
+
import {
|
|
12
|
+
readStore,
|
|
13
|
+
writeStore,
|
|
14
|
+
isFresh,
|
|
15
|
+
toEpochMs,
|
|
16
|
+
renewOnce,
|
|
17
|
+
otherSlot,
|
|
18
|
+
currentKey,
|
|
19
|
+
safeAccount,
|
|
20
|
+
} from "./key-store.mjs"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 从 auth 里取账号标识。org 是组织码,登录后固定不变,最稳。
|
|
24
|
+
*/
|
|
25
|
+
export function accountOf(auth) {
|
|
26
|
+
return safeAccount(auth?.org || auth?.accountId || auth?.uid || "default")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 拿到这个账号一把能用的 key。**永不抛** —— 续不上就交出手里那把(哪怕不新鲜),
|
|
31
|
+
* 让请求自己去撞上游的错误,比在这里抛更能说明问题。
|
|
32
|
+
*
|
|
33
|
+
* @param {object} c 站点上下文
|
|
34
|
+
* @param {Function} getAuth magpie 的取账号函数,或直接给 auth 对象
|
|
35
|
+
* @param {{leadMs?:number}} opts 提前多久算「不新鲜」,默认 10 分钟
|
|
36
|
+
*/
|
|
37
|
+
export async function ensureKey(c, getAuth, { leadMs } = {}) {
|
|
38
|
+
let auth = null
|
|
39
|
+
try {
|
|
40
|
+
auth = typeof getAuth === "function" ? await getAuth() : getAuth
|
|
41
|
+
} catch {}
|
|
42
|
+
const account = accountOf(auth)
|
|
43
|
+
|
|
44
|
+
const st = readStore(c.id, account)
|
|
45
|
+
const cur = currentKey(st)
|
|
46
|
+
if (isFresh(cur, leadMs)) return cur
|
|
47
|
+
|
|
48
|
+
const renewed = await renewOnce(c.id, account, async () => {
|
|
49
|
+
const again = readStore(c.id, account)
|
|
50
|
+
const againCur = currentKey(again)
|
|
51
|
+
// 可能别的请求刚续过 —— 别再签一次(refresh_token 一次性)
|
|
52
|
+
if (isFresh(againCur, leadMs)) return againCur
|
|
53
|
+
|
|
54
|
+
let refreshToken = again.refreshToken
|
|
55
|
+
|
|
56
|
+
// ── 该账号第一次跑:从 magpie 存的凭据接管一次 ────────────────────
|
|
57
|
+
if (!againCur && !refreshToken && auth?.access) {
|
|
58
|
+
const rec = { apiKey: auth.access, expiresAt: Number(auth.expires) || 0 }
|
|
59
|
+
const seeded = {
|
|
60
|
+
current: again.current,
|
|
61
|
+
refreshToken: auth.refresh || "",
|
|
62
|
+
keys: { ...again.keys, [again.current]: rec },
|
|
63
|
+
at: again.at,
|
|
64
|
+
}
|
|
65
|
+
writeStore(c.id, account, seeded)
|
|
66
|
+
if (isFresh(rec, leadMs)) return rec
|
|
67
|
+
refreshToken = seeded.refreshToken
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!refreshToken) return againCur ?? null
|
|
71
|
+
|
|
72
|
+
// ── 续期:签到**另一个**槽再切过去;旧的留在原槽当回退 ────────────
|
|
73
|
+
const k = await refreshGatewayKey(refreshToken, c.host)
|
|
74
|
+
const rec = { apiKey: k.apiKey, expiresAt: toEpochMs(k.apiKeyExpiresAt) }
|
|
75
|
+
const slot = otherSlot(again.current)
|
|
76
|
+
writeStore(c.id, account, {
|
|
77
|
+
current: slot,
|
|
78
|
+
refreshToken: k.refreshToken || refreshToken,
|
|
79
|
+
keys: { ...again.keys, [slot]: rec },
|
|
80
|
+
at: again.at,
|
|
81
|
+
})
|
|
82
|
+
return rec
|
|
83
|
+
})
|
|
84
|
+
if (renewed) return renewed
|
|
85
|
+
|
|
86
|
+
// 续不上:交出手里那把(可能是死的,但比"没有"更清楚)
|
|
87
|
+
if (cur) return cur
|
|
88
|
+
if (auth?.access) return { apiKey: auth.access, expiresAt: 0 }
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 登录成功后签一把并落盘:写进「另一个槽」再切过去,原来那把留着当回退。
|
|
94
|
+
* @returns {Promise<{apiKey,expiresAt,refreshToken}>}
|
|
95
|
+
*/
|
|
96
|
+
export async function issueAndStore(c, jwt, org, account) {
|
|
97
|
+
const acc = safeAccount(account || org)
|
|
98
|
+
const st = readStore(c.id, acc)
|
|
99
|
+
const k = await issueGatewayKey(jwt, org, c.host)
|
|
100
|
+
const rec = { apiKey: k.apiKey, expiresAt: toEpochMs(k.apiKeyExpiresAt) }
|
|
101
|
+
const slot = otherSlot(st.current)
|
|
102
|
+
writeStore(c.id, acc, {
|
|
103
|
+
current: slot,
|
|
104
|
+
refreshToken: k.refreshToken || st.refreshToken,
|
|
105
|
+
keys: { ...st.keys, [slot]: rec },
|
|
106
|
+
at: st.at,
|
|
107
|
+
})
|
|
108
|
+
return { ...rec, refreshToken: k.refreshToken || st.refreshToken, account: acc }
|
|
109
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// 模型上下文窗口 —— 向 magpie 自己的模型库查
|
|
2
|
+
//
|
|
3
|
+
// ═══ 为什么不能自己填一个值 ═══════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// magicrew 的两个模型接口**都不返回上下文窗口**:
|
|
6
|
+
// GET /api/v1/flows/models → value / label / vision / tags / configs
|
|
7
|
+
// GET /v1/models → id / object / created_at / owner_by
|
|
8
|
+
//
|
|
9
|
+
// 早前这里一律填 256_000 当保守值,后果是反的:
|
|
10
|
+
// `pi-ai detected context overflow for model "magicrew/deepseek-v4.1-flash"`
|
|
11
|
+
// —— agent 拿 256K 判断"放不下了",而实际窗口是 1M,白砍掉四分之三。
|
|
12
|
+
//
|
|
13
|
+
// magpie 自己有一份模型库(`~/.cache/magpie/models.json`,按厂商分组,
|
|
14
|
+
// 每条带 `limit.context`),它本来就随 magpie 更新。**向它查,不要自己编。**
|
|
15
|
+
//
|
|
16
|
+
// 查不到才退回保守值 —— 宁可保守,不要给出超过真实窗口的数(那会让 agent
|
|
17
|
+
// 以为还有余量,请求超了上游才报错)。
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs"
|
|
20
|
+
import { homedir } from "node:os"
|
|
21
|
+
import { join } from "node:path"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 查不到时的兜底:**1M**。
|
|
25
|
+
*
|
|
26
|
+
* 优先级从高到低:
|
|
27
|
+
* ① 用户在 magpie GUI 里手选的(「上下文窗口」那一行)—— magpie 直接用它,插件的值只是默认
|
|
28
|
+
* ② 本文件从 magpie 模型库查到的真实值(models.dev 数据)
|
|
29
|
+
* ③ 这个兜底
|
|
30
|
+
*
|
|
31
|
+
* ⚠ 兜底故意给大的:给 1M 而实际只有 128K 时,上游会在超限时报错,用户看得到;
|
|
32
|
+
* 给 256K 而实际有 1M 时,agent 会主动拒绝一段本可以处理的对话 —— 那是静默的能力损失,
|
|
33
|
+
* 比报错更难查(`pi-ai detected context overflow` 就是这么来的)。
|
|
34
|
+
*/
|
|
35
|
+
const FALLBACK_CONTEXT = 1_048_576
|
|
36
|
+
|
|
37
|
+
let cache = null
|
|
38
|
+
let cacheAt = 0
|
|
39
|
+
|
|
40
|
+
/** models.json 5-6MB,读一次缓存住;10 分钟内不重复读 */
|
|
41
|
+
const CACHE_MS = 10 * 60_000
|
|
42
|
+
|
|
43
|
+
function loadLibrary() {
|
|
44
|
+
if (cache && Date.now() - cacheAt < CACHE_MS) return cache
|
|
45
|
+
const idx = new Map()
|
|
46
|
+
try {
|
|
47
|
+
const raw = readFileSync(join(homedir(), ".cache", "magpie", "models.json"), "utf8")
|
|
48
|
+
const walk = (o) => {
|
|
49
|
+
if (Array.isArray(o)) {
|
|
50
|
+
for (const v of o) walk(v)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (!o || typeof o !== "object") return
|
|
54
|
+
const id = typeof o.id === "string" ? o.id : ""
|
|
55
|
+
const ctx = o.limit?.context ?? o.context_length ?? o.context_window
|
|
56
|
+
if (id && Number(ctx) > 0) {
|
|
57
|
+
idx.set(id.toLowerCase(), Number(ctx))
|
|
58
|
+
// 同一个模型可能挂在多个厂商下(deepseek-ai/x、Pro/deepseek-ai/x …),
|
|
59
|
+
// 只保留最大值 —— 那才是它真能收的
|
|
60
|
+
const tail = id.toLowerCase().split("/").pop()
|
|
61
|
+
if (tail) {
|
|
62
|
+
const prev = idx.get(`~${tail}`) || 0
|
|
63
|
+
if (Number(ctx) > prev) idx.set(`~${tail}`, Number(ctx))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const v of Object.values(o)) walk(v)
|
|
67
|
+
}
|
|
68
|
+
walk(JSON.parse(raw))
|
|
69
|
+
} catch {}
|
|
70
|
+
cache = idx
|
|
71
|
+
cacheAt = Date.now()
|
|
72
|
+
return idx
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 这个模型的上下文窗口。
|
|
77
|
+
* @param {string} modelId 插件侧的模型 id,如 `deepseek-v4.1-flash`
|
|
78
|
+
* @returns {number} 字节按 token 计;查不到给保守值
|
|
79
|
+
*/
|
|
80
|
+
export function contextOf(modelId) {
|
|
81
|
+
const id = String(modelId || "").toLowerCase()
|
|
82
|
+
if (!id) return FALLBACK_CONTEXT
|
|
83
|
+
const idx = loadLibrary()
|
|
84
|
+
|
|
85
|
+
// ① 全名命中(插件 id 恰好等于库里的 id)
|
|
86
|
+
if (idx.has(id)) return idx.get(id)
|
|
87
|
+
|
|
88
|
+
// ② 尾段命中:库里的 `deepseek-ai/deepseek-v4.1-flash` ↔ 我们的 `deepseek-v4.1-flash`
|
|
89
|
+
if (idx.has(`~${id}`)) return idx.get(`~${id}`)
|
|
90
|
+
|
|
91
|
+
// ③ 同一个模型挂在不同厂商前缀下,取最大的那个(真能收的容量)
|
|
92
|
+
let best = 0
|
|
93
|
+
for (const [k, v] of idx) {
|
|
94
|
+
if (k.startsWith("~")) continue
|
|
95
|
+
if (k.endsWith(`/${id}`) && v > best) best = v
|
|
96
|
+
}
|
|
97
|
+
return best || FALLBACK_CONTEXT
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const _internal = { FALLBACK_CONTEXT, loadLibrary }
|
|
@@ -0,0 +1,234 @@
|
|
|
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 }
|