cardbuddy 0.19.3 → 0.21.0
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/README.md +8 -8
- package/dist/cli.js +21 -8
- package/dist/init.js +3 -3
- package/dist/preview-editor/assets/codeBridgeHeartbeat.worker-Zb_A5RUd.js +1 -0
- package/dist/preview-editor/assets/index-C8VLzU18.css +1 -0
- package/dist/preview-editor/assets/index-CpDojF__.js +383 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/skills-upgrade.js +64 -25
- package/dist/standalone.js +49 -0
- package/dist/vendored/card-editor/components/MultiColumn.vue +19 -10
- package/dist/vendored/card-editor/runtime/card-runtime.js +1 -1
- package/dist/vendored/mcp/client.mjs +22 -3
- package/dist/vendored/server/core/ai-chat.js +92 -12
- package/dist/vendored/server/core/cardExport.js +831 -0
- package/dist/vendored/server/core/cardHistoryRemote.js +203 -0
- package/dist/vendored/server/core/cardImportMeta.js +6 -1
- package/dist/vendored/server/core/cardPublishVersions.js +91 -0
- package/dist/vendored/server/core/cardSave.js +143 -6
- package/dist/vendored/server/core/cardSync.js +467 -95
- package/dist/vendored/server/core/codeBridge.js +4 -2
- package/dist/vendored/server/core/index.js +16 -0
- package/dist/vendored/skills/finmall-card-sync/SKILL.md +46 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +657 -15
- package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +79 -8
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +25 -1
- package/dist/vendored/skills/mbank-design-gen/tools/screenshot.mjs +10 -1
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-C_uqjKqI.css +0 -1
- package/dist/preview-editor/assets/index-DRzBK4qj.js +0 -347
|
@@ -1,113 +1,351 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { pathToFileURL } from 'node:url'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
|
-
import { writeFile, mkdir } from 'node:fs/promises'
|
|
5
4
|
import { checkPushAllowed, refreshCardList, hasCardId } from './cardListCache.js'
|
|
6
5
|
import { assertRemoteSource } from './cardSidecar.js'
|
|
7
6
|
import { log, fmtBytes } from './logger.js'
|
|
8
7
|
import { buildImportMeta, writeImportMeta } from './cardImportMeta.js'
|
|
9
|
-
import { resolveSkillPathOrProject } from './skillPaths.js'
|
|
8
|
+
import { resolveSkillPathOrProject, projectSkillsDir } from './skillPaths.js'
|
|
10
9
|
|
|
11
10
|
/** finmall-card-sync 的批量同步脚本(同步 / 远端预览共用),相对 skills/ */
|
|
12
11
|
const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
|
|
13
12
|
|
|
13
|
+
/** 卡片 id 合法形态(与 normalizeCard / 新建卡同源正则) */
|
|
14
|
+
const CARD_CODE_RE = /^[A-Za-z0-9_-]+$/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 同步任务硬超时(看门狗)。
|
|
18
|
+
*
|
|
19
|
+
* 为什么必须有:同步中间件是单飞制(`activeSyncJob()` 未终态时,之后每次 POST /__sync 都拿
|
|
20
|
+
* 409「已有同步任务正在进行」)。而同步链路上游仍有「await 一个永不 settle 的 Promise」的
|
|
21
|
+
* 可能(page.evaluate 无默认超时、页面内 fetch 挂起、CDP 连接半死等)——一旦某个任务挂在
|
|
22
|
+
* 这种 await 上,`finally { job.finished = true }` 永远不执行,单飞锁被永久占用,
|
|
23
|
+
* 用户侧表现就是「点了远端同步、服务端再也不响应」。
|
|
24
|
+
* 看门狗到点把任务判超时收尾(置 cancelRequested 让 syncCards 在下一张卡片前退出、
|
|
25
|
+
* 直接标 finished 释放锁),保证任何单次同步都不会无限期占用服务。
|
|
26
|
+
*/
|
|
27
|
+
const SYNC_JOB_TIMEOUT_MS = Number(process.env.CARDBUDDY_SYNC_JOB_TIMEOUT_MS || 10 * 60 * 1000)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 同步任务表(进程内单例)。
|
|
31
|
+
* 任务制模型:POST /__sync 立即返回 jobId,syncCards 在后台跑;
|
|
32
|
+
* 前端经 GET /__sync/progress 轮询进度、POST /__sync/cancel 取消。
|
|
33
|
+
* 保留最近若干个终态任务,供「轮询晚于完成」的前端仍能取到最终结果。
|
|
34
|
+
*/
|
|
35
|
+
const syncJobs = new Map() // jobId -> job
|
|
36
|
+
const SYNC_JOB_KEEP = 8
|
|
37
|
+
let syncJobSeq = 0
|
|
38
|
+
|
|
39
|
+
/** 取当前活跃(未完成)同步任务;无则 null */
|
|
40
|
+
function activeSyncJob() {
|
|
41
|
+
for (const job of syncJobs.values()) {
|
|
42
|
+
if (!job.finished) return job
|
|
43
|
+
}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 终态任务入表并淘汰最旧的(保持 Map 插入序 = 创建序) */
|
|
48
|
+
function rememberSyncJob(job) {
|
|
49
|
+
syncJobs.set(job.id, job)
|
|
50
|
+
while (syncJobs.size > SYNC_JOB_KEEP) {
|
|
51
|
+
const oldest = syncJobs.keys().next().value
|
|
52
|
+
syncJobs.delete(oldest)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 判断是否为「凭据缺失」类错误(与旧 /__sync 行为一致:转 needCredentials 让前端弹登录表单) */
|
|
57
|
+
function isNoCredentialsError(e) {
|
|
58
|
+
return (
|
|
59
|
+
!!e &&
|
|
60
|
+
(e.code === 'NO_CREDENTIALS' ||
|
|
61
|
+
/credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(String(e && e.message)))
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 同步完成后的终端汇总打印(与旧实现同款美化框,便于 dev server 日志排查) */
|
|
66
|
+
function logSyncSummary(summary, requestLabel) {
|
|
67
|
+
const lines = []
|
|
68
|
+
lines.push(`请求卡片:${requestLabel}`)
|
|
69
|
+
if (summary.synced && summary.synced.length) {
|
|
70
|
+
lines.push(`已同步 ${summary.synced.length} 张:`)
|
|
71
|
+
for (const s of summary.synced) {
|
|
72
|
+
const f = s.files || {}
|
|
73
|
+
// 空白卡(远端无 schemaValue):标注落盘策略,避免误读成「抓到了完整 schema」。
|
|
74
|
+
const tag = s.blank
|
|
75
|
+
? s.keptLocal
|
|
76
|
+
? ' [空白卡·保留本地]'
|
|
77
|
+
: ' [空白卡·新建空白 schema]'
|
|
78
|
+
: ''
|
|
79
|
+
lines.push(
|
|
80
|
+
`• ${s.id}${s.title ? `(${s.title})` : ''}${tag} schema=${fmtBytes(f.schema)} data=${fmtBytes(f.data)} cover=${fmtBytes(f.cover)}`
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
lines.push('已同步 0 张')
|
|
85
|
+
}
|
|
86
|
+
if (summary.failed && summary.failed.length) {
|
|
87
|
+
lines.push(`失败 ${summary.failed.length} 张:`)
|
|
88
|
+
for (const f of summary.failed) {
|
|
89
|
+
lines.push(`✗ ${f.id || ''} ${f.error || ''}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (summary.duplicates && summary.duplicates.length) {
|
|
93
|
+
lines.push(`重复 schema ${summary.duplicates.length} 项:${summary.duplicates.map((d) => d.id).join('、')}`)
|
|
94
|
+
}
|
|
95
|
+
log.box({ title: '远端同步完成', level: summary.success ? 'ok' : 'warn', lines })
|
|
96
|
+
}
|
|
97
|
+
|
|
14
98
|
/**
|
|
15
99
|
* 预览「同步远程数据」按钮的服务端处理:经已认证的 FinMall 浏览器会话拉取最新卡片写回 src/cards。
|
|
16
100
|
* 复用 finmall-card-sync 的 syncCards(connectOverCDP 连用户已开的 Chrome,disconnect 不杀进程)。
|
|
17
101
|
*
|
|
102
|
+
* 任务制端点(编辑器「远端 → 查看全部 → 下载选中 / 全部下载」的进度弹窗依赖):
|
|
103
|
+
* - POST /__sync body { cardCode? , cardCodes? } → 立即返回 { ok, jobId, total }
|
|
104
|
+
* - GET /__sync/progress?jobId → { ok, done, total, currentId, currentTitle, finished, result }
|
|
105
|
+
* - POST /__sync/cancel body { jobId } → 设取消探针,下一张卡片前中止
|
|
106
|
+
* - GET /__remote-cards → { ok, cards:[{id,title,cardId,coverUrl}] }(只读全量列表)
|
|
107
|
+
*
|
|
18
108
|
* @param {{ root: string }} opts
|
|
19
109
|
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
20
110
|
*/
|
|
21
111
|
export function createSyncMiddleware({ root }) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (pathname !== '/__sync') return next()
|
|
25
|
-
if ((req.method || 'GET').toUpperCase() !== 'POST') return next()
|
|
26
|
-
|
|
112
|
+
/** 读 JSON body(上限 1MB,与旧实现一致) */
|
|
113
|
+
const readJsonBody = (req, res, cb) => {
|
|
27
114
|
let body = ''
|
|
28
115
|
req.setEncoding('utf8')
|
|
29
116
|
req.on('data', (c) => {
|
|
30
117
|
body += c
|
|
31
|
-
if (body.length >
|
|
118
|
+
if (body.length > 1024 * 1024) req.destroy()
|
|
32
119
|
})
|
|
33
|
-
req.on('end',
|
|
34
|
-
let payload
|
|
120
|
+
req.on('end', () => {
|
|
35
121
|
try {
|
|
36
|
-
|
|
122
|
+
cb(JSON.parse(body || '{}'))
|
|
37
123
|
} catch (e) {
|
|
38
124
|
res.statusCode = 400
|
|
39
125
|
res.setHeader('Content-Type', 'application/json')
|
|
40
|
-
|
|
126
|
+
res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
|
|
41
127
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return function syncMiddleware(req, res, next) {
|
|
132
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
133
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
134
|
+
|
|
135
|
+
// ---- 远端完整卡片列表(只读):编辑器「查看全部」网格数据源 ----
|
|
136
|
+
if (pathname === '/__remote-cards') {
|
|
137
|
+
if (method !== 'GET') return next()
|
|
138
|
+
// 与同步共用后台 Chrome 会话,并发会互相干扰:同步进行中直接拒绝,让前端稍后重试
|
|
139
|
+
if (activeSyncJob()) {
|
|
140
|
+
res.statusCode = 409
|
|
141
|
+
res.setHeader('Content-Type', 'application/json')
|
|
142
|
+
return res.end(
|
|
143
|
+
JSON.stringify({ ok: false, error: 'sync_in_progress', message: '正在拉取卡片,请稍候再查看远端列表' })
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
;(async () => {
|
|
147
|
+
try {
|
|
148
|
+
const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
149
|
+
const { listRemoteCards } = await import(pathToFileURL(skillPath).href)
|
|
150
|
+
const cards = await listRemoteCards({})
|
|
151
|
+
res.statusCode = 200
|
|
152
|
+
res.setHeader('Content-Type', 'application/json')
|
|
153
|
+
return res.end(JSON.stringify({ ok: true, cards }))
|
|
154
|
+
} catch (e) {
|
|
155
|
+
if (isNoCredentialsError(e)) {
|
|
156
|
+
res.statusCode = 200
|
|
157
|
+
res.setHeader('Content-Type', 'application/json')
|
|
158
|
+
return res.end(
|
|
159
|
+
JSON.stringify({
|
|
160
|
+
ok: false,
|
|
161
|
+
error: 'need_credentials',
|
|
162
|
+
needCredentials: true,
|
|
163
|
+
message: String(e && e.message),
|
|
164
|
+
})
|
|
71
165
|
)
|
|
72
166
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
lines.push(`失败 ${summary.failed.length} 张:`)
|
|
78
|
-
for (const f of summary.failed) {
|
|
79
|
-
lines.push(`✗ ${f.id || ''} ${f.error || ''}`)
|
|
80
|
-
}
|
|
167
|
+
log.error('拉取远端卡片列表失败', e)
|
|
168
|
+
res.statusCode = 500
|
|
169
|
+
res.setHeader('Content-Type', 'application/json')
|
|
170
|
+
return res.end(JSON.stringify({ ok: false, error: 'list failed', message: String(e && e.message) }))
|
|
81
171
|
}
|
|
82
|
-
|
|
83
|
-
|
|
172
|
+
})()
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- 同步进度查询(轮询) ----
|
|
177
|
+
if (pathname === '/__sync/progress') {
|
|
178
|
+
if (method !== 'GET') return next()
|
|
179
|
+
const jobId = new URL(req.url, 'http://127.0.0.1').searchParams.get('jobId') || ''
|
|
180
|
+
const job = syncJobs.get(jobId)
|
|
181
|
+
if (!job) {
|
|
182
|
+
res.statusCode = 404
|
|
183
|
+
res.setHeader('Content-Type', 'application/json')
|
|
184
|
+
return res.end(JSON.stringify({ ok: false, error: 'unknown job', message: '同步任务不存在或已过期' }))
|
|
185
|
+
}
|
|
186
|
+
res.statusCode = 200
|
|
187
|
+
res.setHeader('Content-Type', 'application/json')
|
|
188
|
+
return res.end(
|
|
189
|
+
JSON.stringify({
|
|
190
|
+
ok: true,
|
|
191
|
+
jobId: job.id,
|
|
192
|
+
done: job.progress.done,
|
|
193
|
+
total: job.progress.total,
|
|
194
|
+
currentId: job.progress.currentId,
|
|
195
|
+
currentTitle: job.progress.currentTitle,
|
|
196
|
+
finished: job.finished,
|
|
197
|
+
result: job.result,
|
|
198
|
+
})
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- 取消同步(下一张卡片前生效;已落盘卡片保留) ----
|
|
203
|
+
if (pathname === '/__sync/cancel') {
|
|
204
|
+
if (method !== 'POST') return next()
|
|
205
|
+
return readJsonBody(req, res, (payload) => {
|
|
206
|
+
const jobId = String(payload.jobId || '')
|
|
207
|
+
const job = syncJobs.get(jobId)
|
|
208
|
+
if (!job || job.finished) {
|
|
209
|
+
res.statusCode = 404
|
|
210
|
+
res.setHeader('Content-Type', 'application/json')
|
|
211
|
+
return res.end(JSON.stringify({ ok: false, error: 'unknown job', message: '同步任务不存在或已结束' }))
|
|
84
212
|
}
|
|
85
|
-
|
|
213
|
+
job.cancelRequested = true
|
|
86
214
|
res.statusCode = 200
|
|
87
215
|
res.setHeader('Content-Type', 'application/json')
|
|
88
|
-
return res.end(JSON.stringify(
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
216
|
+
return res.end(JSON.stringify({ ok: true }))
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (pathname !== '/__sync') return next()
|
|
221
|
+
if (method !== 'POST') return next()
|
|
222
|
+
|
|
223
|
+
return readJsonBody(req, res, (payload) => {
|
|
224
|
+
// 入参:单卡 cardCode / 集合 cardCodes(二选一,单卡优先)
|
|
225
|
+
const cardCode =
|
|
226
|
+
typeof payload.cardCode === 'string' && CARD_CODE_RE.test(payload.cardCode)
|
|
227
|
+
? payload.cardCode
|
|
228
|
+
: undefined
|
|
229
|
+
let cardCodes
|
|
230
|
+
if (Array.isArray(payload.cardCodes)) {
|
|
231
|
+
const set = new Set(
|
|
232
|
+
payload.cardCodes.filter((c) => typeof c === 'string' && CARD_CODE_RE.test(c))
|
|
233
|
+
)
|
|
234
|
+
if (set.size > 500) {
|
|
235
|
+
res.statusCode = 400
|
|
236
|
+
res.setHeader('Content-Type', 'application/json')
|
|
237
|
+
return res.end(
|
|
238
|
+
JSON.stringify({ ok: false, error: 'too_many', message: '单次下载勾选不能超过 500 张' })
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
if (set.size) cardCodes = [...set]
|
|
242
|
+
}
|
|
243
|
+
if (!cardCode && !cardCodes && payload.cardCode != null) {
|
|
244
|
+
// 传了 cardCode 但不合法:明确拒绝,避免静默变成全量
|
|
245
|
+
res.statusCode = 400
|
|
98
246
|
res.setHeader('Content-Type', 'application/json')
|
|
99
247
|
return res.end(
|
|
100
|
-
JSON.stringify(
|
|
101
|
-
isNoCred
|
|
102
|
-
? {
|
|
103
|
-
error: 'need_credentials',
|
|
104
|
-
needCredentials: true,
|
|
105
|
-
message: String(e && e.message),
|
|
106
|
-
}
|
|
107
|
-
: { error: 'sync failed', message: String(e && e.message) },
|
|
108
|
-
),
|
|
248
|
+
JSON.stringify({ ok: false, error: 'invalid cardCode', message: 'cardCode 含有非法字符' })
|
|
109
249
|
)
|
|
110
250
|
}
|
|
251
|
+
// 同一时刻只允许一个同步任务(全量同步动辄数分钟,并发会互相打断)
|
|
252
|
+
if (activeSyncJob()) {
|
|
253
|
+
res.statusCode = 409
|
|
254
|
+
res.setHeader('Content-Type', 'application/json')
|
|
255
|
+
return res.end(
|
|
256
|
+
JSON.stringify({ ok: false, error: 'sync_in_progress', message: '已有同步任务正在进行,请等待完成或取消' })
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
syncJobSeq += 1
|
|
261
|
+
const job = {
|
|
262
|
+
id: `sj-${Date.now().toString(36)}-${syncJobSeq}`,
|
|
263
|
+
createdAt: Date.now(),
|
|
264
|
+
cancelRequested: false,
|
|
265
|
+
timedOut: false,
|
|
266
|
+
finished: false,
|
|
267
|
+
progress: { done: 0, total: cardCode ? 1 : cardCodes ? cardCodes.length : 0, currentId: '', currentTitle: '' },
|
|
268
|
+
result: null,
|
|
269
|
+
}
|
|
270
|
+
rememberSyncJob(job)
|
|
271
|
+
|
|
272
|
+
// 看门狗:硬超时兜底。同步链路里仍有「await 一个永不 settle 的 Promise」的可能
|
|
273
|
+
// (page.evaluate 无默认超时 / 页面内 fetch 挂起 / CDP 半死),一旦挂住,下面 IIFE 的
|
|
274
|
+
// finally 永远不执行 → 单飞锁被永久占用 → 之后每次同步都 409,用户侧就是「服务端僵死」。
|
|
275
|
+
// 到点后代它收尾:置 cancelRequested 让 syncCards 在下一张卡片前退出,并直接标 finished
|
|
276
|
+
// 释放锁;后台任务若稍后真的返回,也以看门狗的结论为准(不再回滚成「成功」误导用户)。
|
|
277
|
+
const watchdog = setTimeout(() => {
|
|
278
|
+
if (job.finished) return
|
|
279
|
+
job.timedOut = true
|
|
280
|
+
job.cancelRequested = true
|
|
281
|
+
job.finished = true
|
|
282
|
+
job.result = {
|
|
283
|
+
success: false,
|
|
284
|
+
timedOut: true,
|
|
285
|
+
error: `同步任务超过 ${Math.round(SYNC_JOB_TIMEOUT_MS / 1000)} 秒未完成,已自动收尾(上游无响应)。请重试,或检查 FinMall 登录态 / 网络。`,
|
|
286
|
+
}
|
|
287
|
+
log.error('远端同步看门狗触发:任务超时,已代其收尾并释放同步锁', {
|
|
288
|
+
jobId: job.id,
|
|
289
|
+
timeoutMs: SYNC_JOB_TIMEOUT_MS,
|
|
290
|
+
done: job.progress.done,
|
|
291
|
+
currentId: job.progress.currentId,
|
|
292
|
+
})
|
|
293
|
+
}, SYNC_JOB_TIMEOUT_MS)
|
|
294
|
+
if (typeof watchdog.unref === 'function') watchdog.unref()
|
|
295
|
+
|
|
296
|
+
// 后台跑同步:请求立即返回,前端轮询 progress
|
|
297
|
+
;(async () => {
|
|
298
|
+
try {
|
|
299
|
+
const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
300
|
+
const { syncCards } = await import(pathToFileURL(skillPath).href)
|
|
301
|
+
const summary = await syncCards({
|
|
302
|
+
cardCode,
|
|
303
|
+
cardCodes,
|
|
304
|
+
outRoot: path.join(root, 'src', 'cards'),
|
|
305
|
+
// 从 server/core 上下文注入 cardImportMeta 实现:消费者工程下 api_batch_sync.mjs 按自身
|
|
306
|
+
// skills 位置推导的 workspace 会指向错误根,动态 import 找不到 cardImportMeta.js。
|
|
307
|
+
importMeta: { buildImportMeta, writeImportMeta },
|
|
308
|
+
// 从 server/core 上下文注入 cardListCache 实现:同 cardImportMeta,消费者工程下按自身 skills
|
|
309
|
+
// 位置推导的 workspace 会指向错误根,动态 import 找不到 cardListCache.js,导致全量同步时无法
|
|
310
|
+
// 刷新进程内单例,推送前「存在性闸门」形同虚设。注入后三态都能正确刷新。
|
|
311
|
+
cardListCache: { refreshCardList, checkPushAllowed, hasCardId },
|
|
312
|
+
onProgress: (p) => {
|
|
313
|
+
job.progress = {
|
|
314
|
+
done: Number(p.done) || 0,
|
|
315
|
+
total: p.total == null ? job.progress.total : Number(p.total) || 0,
|
|
316
|
+
currentId: p.currentId || '',
|
|
317
|
+
currentTitle: p.currentTitle || '',
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
shouldCancel: () => job.cancelRequested,
|
|
321
|
+
})
|
|
322
|
+
// 看门狗已代收尾时不回滚结论:用户当时已经收到「超时」结论,迟到的成功会造成错觉
|
|
323
|
+
if (!job.timedOut) {
|
|
324
|
+
job.result = {
|
|
325
|
+
success: !!summary.success,
|
|
326
|
+
cancelled: !!summary.cancelled,
|
|
327
|
+
synced: summary.synced || [],
|
|
328
|
+
failed: summary.failed || [],
|
|
329
|
+
duplicates: summary.duplicates || [],
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
logSyncSummary(summary, cardCode ? cardCode : cardCodes ? `勾选 ${cardCodes.length} 张` : '(全部)')
|
|
333
|
+
} catch (e) {
|
|
334
|
+
if (isNoCredentialsError(e)) {
|
|
335
|
+
job.result = { needCredentials: true, message: String(e && e.message) }
|
|
336
|
+
} else {
|
|
337
|
+
job.result = { success: false, error: String(e && e.message) }
|
|
338
|
+
log.error('远端同步失败', e)
|
|
339
|
+
}
|
|
340
|
+
} finally {
|
|
341
|
+
clearTimeout(watchdog)
|
|
342
|
+
job.finished = true
|
|
343
|
+
}
|
|
344
|
+
})()
|
|
345
|
+
|
|
346
|
+
res.statusCode = 200
|
|
347
|
+
res.setHeader('Content-Type', 'application/json')
|
|
348
|
+
return res.end(JSON.stringify({ ok: true, jobId: job.id, total: job.progress.total }))
|
|
111
349
|
})
|
|
112
350
|
}
|
|
113
351
|
}
|
|
@@ -190,10 +428,110 @@ export function createPreviewMiddleware({ root }) {
|
|
|
190
428
|
}
|
|
191
429
|
}
|
|
192
430
|
|
|
431
|
+
/** finmall-session-navigator 登录脚本(凭据读写 + 页面内登录验证),相对 skills/ */
|
|
432
|
+
const SESSION_LOGIN_REL = 'finmall-session-navigator/scripts/finmall_login.mjs'
|
|
433
|
+
/** finmall-session-navigator 后台会话管理(同步 / 登录验证共用同一 headless Chrome) */
|
|
434
|
+
const SESSION_MANAGER_REL = 'finmall-session-navigator/scripts/session-manager.mjs'
|
|
435
|
+
|
|
436
|
+
/** 凭据落盘路径:与 finmall_login.mjs 默认路径同址,但经 projectSkillsDir 永远写项目根 skills/,不进 node_modules */
|
|
437
|
+
function credentialsFilePath(root) {
|
|
438
|
+
return path.join(
|
|
439
|
+
projectSkillsDir(root),
|
|
440
|
+
'finmall-session-navigator',
|
|
441
|
+
'.local',
|
|
442
|
+
'credentials.json',
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** 动态加载 finmall-session-navigator 登录脚本(仓库内 / 消费者工程 / 包内快照三态通用) */
|
|
447
|
+
async function loadLoginModule(root) {
|
|
448
|
+
const p = resolveSkillPathOrProject(root, SESSION_LOGIN_REL)
|
|
449
|
+
return import(pathToFileURL(p).href)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 读取已落盘凭据的登录名;缺失 / 损坏 / 格式不合法一律返回 null(不返回密码哈希) */
|
|
453
|
+
async function readSavedLoginName(root) {
|
|
454
|
+
try {
|
|
455
|
+
const { loadCredentials } = await loadLoginModule(root)
|
|
456
|
+
const creds = await loadCredentials(credentialsFilePath(root))
|
|
457
|
+
return creds.loginName || null
|
|
458
|
+
} catch {
|
|
459
|
+
return null
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* 用给定账号密码做一次真实登录验证(不落盘)。
|
|
465
|
+
* 与「同步」共用同一后台 headless Chrome 会话池:连上 / 拉起后在 FinMall 同源页面内
|
|
466
|
+
* 直调登录接口(performLogin),以是否建立会话(sessionCreated)判定凭据是否正确——
|
|
467
|
+
* 已登录会话不会蒙混过关,因为performLogin 每次都真实提交一遍账号密码。
|
|
468
|
+
* 只 disconnect 不关闭浏览器:后台 Chrome 以 detached 常驻,供后续同步复用。
|
|
469
|
+
*
|
|
470
|
+
* @returns {Promise<{ sessionCreated: boolean, businessFailure: boolean, status?: number }>}
|
|
471
|
+
*/
|
|
472
|
+
async function verifyFinmallLogin(root, loginName, rawPassword) {
|
|
473
|
+
const {
|
|
474
|
+
performLogin,
|
|
475
|
+
tryConnectBrowser,
|
|
476
|
+
launchChromeDetached,
|
|
477
|
+
findChromePath,
|
|
478
|
+
waitForDebugPort,
|
|
479
|
+
TARGET_URL,
|
|
480
|
+
} = await loadLoginModule(root)
|
|
481
|
+
const managerPath = resolveSkillPathOrProject(root, SESSION_MANAGER_REL)
|
|
482
|
+
const { SYNC_PORT, SYNC_BROWSER_DATA_DIR } = await import(
|
|
483
|
+
pathToFileURL(managerPath).href,
|
|
484
|
+
)
|
|
485
|
+
const md5Hex = createHash('md5')
|
|
486
|
+
.update(String(rawPassword), 'utf8')
|
|
487
|
+
.digest('hex')
|
|
488
|
+
|
|
489
|
+
let browser = await tryConnectBrowser(SYNC_PORT)
|
|
490
|
+
if (!browser) {
|
|
491
|
+
// 端口未启动:拉起后台 headless Chrome(与 ensureFinMallSession 同端口同数据目录)
|
|
492
|
+
launchChromeDetached(findChromePath(), SYNC_BROWSER_DATA_DIR, SYNC_PORT, TARGET_URL, {
|
|
493
|
+
headless: true,
|
|
494
|
+
})
|
|
495
|
+
const ready = await waitForDebugPort(SYNC_PORT, 20000)
|
|
496
|
+
if (!ready) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`后台 Chrome 调试端口 ${SYNC_PORT} 未就绪,无法完成登录验证(检查 Chrome 是否安装、端口是否被占用)`,
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
for (let i = 0; i < 5 && !browser; i += 1) {
|
|
502
|
+
browser = await tryConnectBrowser(SYNC_PORT)
|
|
503
|
+
if (!browser) await new Promise((r) => setTimeout(r, 400))
|
|
504
|
+
}
|
|
505
|
+
if (!browser) {
|
|
506
|
+
throw new Error(`无法连接后台 Chrome 调试端口 ${SYNC_PORT},无法完成登录验证`)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
const context = browser.contexts()[0]
|
|
511
|
+
let page = context?.pages().find((p) => p.url().includes('finmall.com'))
|
|
512
|
+
if (!page) {
|
|
513
|
+
page = context ? await context.newPage() : await browser.newPage()
|
|
514
|
+
await page
|
|
515
|
+
.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 20000 })
|
|
516
|
+
.catch(() => {})
|
|
517
|
+
}
|
|
518
|
+
return await performLogin(page, { loginName, loginPassword: md5Hex })
|
|
519
|
+
} finally {
|
|
520
|
+
try {
|
|
521
|
+
await browser.disconnect()
|
|
522
|
+
} catch {
|
|
523
|
+
/* 后台 Chrome 常驻,仅断开连接 */
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
193
528
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
529
|
+
* 登录凭据端点(顶栏「远端」的前置门禁):
|
|
530
|
+
* - GET /__login-credentials 查询凭据是否已落盘 → { ok, hasCredentials, loginName }
|
|
531
|
+
* - POST /__login-credentials 提交账号 + 明文密码:先做一次真实登录验证,通过才落盘
|
|
532
|
+
* (MD5,不落明文);账号 / 密码错误返回 ok:false 且不落盘,由前端保留弹窗让用户重填。
|
|
533
|
+
*
|
|
534
|
+
* 旧行为(不验证直接写盘)会把错误密码持久化,且重跑也不再重问——故改为「验证通过才落盘」。
|
|
197
535
|
*
|
|
198
536
|
* @param {{ root: string }} opts
|
|
199
537
|
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
@@ -202,7 +540,30 @@ export function createLoginCredentialsMiddleware({ root }) {
|
|
|
202
540
|
return function loginCredentialsMiddleware(req, res, next) {
|
|
203
541
|
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
204
542
|
if (pathname !== '/__login-credentials') return next()
|
|
205
|
-
|
|
543
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
544
|
+
if (method !== 'GET' && method !== 'POST') return next()
|
|
545
|
+
res.setHeader('Content-Type', 'application/json')
|
|
546
|
+
|
|
547
|
+
if (method === 'GET') {
|
|
548
|
+
readSavedLoginName(root)
|
|
549
|
+
.then((loginName) => {
|
|
550
|
+
res.statusCode = 200
|
|
551
|
+
res.end(
|
|
552
|
+
JSON.stringify({
|
|
553
|
+
ok: true,
|
|
554
|
+
hasCredentials: !!loginName,
|
|
555
|
+
loginName: loginName || '',
|
|
556
|
+
}),
|
|
557
|
+
)
|
|
558
|
+
})
|
|
559
|
+
.catch((e) => {
|
|
560
|
+
res.statusCode = 500
|
|
561
|
+
res.end(
|
|
562
|
+
JSON.stringify({ error: 'status failed', message: String(e && e.message) }),
|
|
563
|
+
)
|
|
564
|
+
})
|
|
565
|
+
return
|
|
566
|
+
}
|
|
206
567
|
|
|
207
568
|
let body = ''
|
|
208
569
|
req.setEncoding('utf8')
|
|
@@ -216,7 +577,6 @@ export function createLoginCredentialsMiddleware({ root }) {
|
|
|
216
577
|
payload = JSON.parse(body || '{}')
|
|
217
578
|
} catch (e) {
|
|
218
579
|
res.statusCode = 400
|
|
219
|
-
res.setHeader('Content-Type', 'application/json')
|
|
220
580
|
return res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
|
|
221
581
|
}
|
|
222
582
|
const loginName =
|
|
@@ -225,7 +585,6 @@ export function createLoginCredentialsMiddleware({ root }) {
|
|
|
225
585
|
typeof payload.loginPassword === 'string' ? payload.loginPassword : ''
|
|
226
586
|
if (!loginName || !rawPassword) {
|
|
227
587
|
res.statusCode = 400
|
|
228
|
-
res.setHeader('Content-Type', 'application/json')
|
|
229
588
|
return res.end(
|
|
230
589
|
JSON.stringify({
|
|
231
590
|
error: 'missing_fields',
|
|
@@ -234,28 +593,41 @@ export function createLoginCredentialsMiddleware({ root }) {
|
|
|
234
593
|
)
|
|
235
594
|
}
|
|
236
595
|
try {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
596
|
+
// 1) 先做一次真实登录验证,通过才落盘
|
|
597
|
+
const result = await verifyFinmallLogin(root, loginName, rawPassword)
|
|
598
|
+
if (result.sessionCreated) {
|
|
599
|
+
const { saveCredentials } = await loadLoginModule(root)
|
|
600
|
+
await saveCredentials(loginName, rawPassword, credentialsFilePath(root))
|
|
601
|
+
log.ok('登录验证通过,已保存登录凭据', { loginName })
|
|
602
|
+
res.statusCode = 200
|
|
603
|
+
return res.end(JSON.stringify({ ok: true, verified: true, loginName }))
|
|
604
|
+
}
|
|
605
|
+
if (result.businessFailure) {
|
|
606
|
+
// 账号 / 密码错误:不落盘,前端保留弹窗让用户重新输入
|
|
607
|
+
log.warn('登录验证未通过:账号或密码错误', { loginName })
|
|
608
|
+
res.statusCode = 200
|
|
609
|
+
return res.end(
|
|
610
|
+
JSON.stringify({
|
|
611
|
+
ok: false,
|
|
612
|
+
error: 'invalid_credentials',
|
|
613
|
+
message: '账号或密码错误,登录验证未通过(凭据未保存)',
|
|
614
|
+
}),
|
|
615
|
+
)
|
|
247
616
|
}
|
|
248
|
-
await mkdir(path.dirname(credentialsPath), { recursive: true })
|
|
249
|
-
await writeFile(credentialsPath, JSON.stringify(credentials, null, 2), 'utf8')
|
|
250
|
-
log.ok('已保存登录凭据', { loginName })
|
|
251
617
|
res.statusCode = 200
|
|
252
|
-
res.
|
|
253
|
-
|
|
618
|
+
return res.end(
|
|
619
|
+
JSON.stringify({
|
|
620
|
+
ok: false,
|
|
621
|
+
error: 'verify_failed',
|
|
622
|
+
message: `登录验证未通过(HTTP ${result.status || '未知'}),凭据未保存`,
|
|
623
|
+
}),
|
|
624
|
+
)
|
|
254
625
|
} catch (e) {
|
|
255
|
-
log.error('
|
|
626
|
+
log.error('登录验证失败', e)
|
|
256
627
|
res.statusCode = 500
|
|
257
|
-
res.
|
|
258
|
-
|
|
628
|
+
return res.end(
|
|
629
|
+
JSON.stringify({ error: 'verify_failed', message: String(e && e.message) }),
|
|
630
|
+
)
|
|
259
631
|
}
|
|
260
632
|
})
|
|
261
633
|
}
|
|
@@ -15,7 +15,9 @@ import { log } from './logger.js'
|
|
|
15
15
|
* 有实质变化经 SSE 推 code-bridge-changed { id }(内容走 GET 拉取,避免大 payload);
|
|
16
16
|
* 3) 前端 GET /__code-bridge/<id> 取回代码写回 CodeJar(updateCode 不触发 onUpdate,断回声环);
|
|
17
17
|
* 4) 会话结束 = 前端 DELETE(面板关闭 / 手动断开 / 组件卸载)或心跳 TTL 兜底
|
|
18
|
-
* (前端 5s 心跳,
|
|
18
|
+
* (前端 5s 心跳,60s 无心跳即删文件,覆盖关浏览器 / 崩溃 / 标签页冻结);启动时清扫 >24h 残留。
|
|
19
|
+
* 心跳由独立 Web Worker 发送,不受编辑器标签切后台后的 page-freeze / 定时器节流影响,
|
|
20
|
+
* 故"在 VSCode 里编辑、没碰编辑器"不会再把桥接掐断(旧 20s TTL 在后台冻结下几乎必断)。
|
|
19
21
|
*
|
|
20
22
|
* 「关闭」语义说明:VSCode CLI 把文件交给已运行实例后立即返回,标签页关闭事件不装扩展
|
|
21
23
|
* 拿不到,故清除时机取「会话结束 + TTL」,而非 VSCode 关标签瞬间。
|
|
@@ -27,7 +29,7 @@ import { log } from './logger.js'
|
|
|
27
29
|
|
|
28
30
|
export const BRIDGE_DIR_NAME = 'cardbuddy-code-bridge'
|
|
29
31
|
export const SESSION_ID_RE = /^[a-f0-9]{8}$/
|
|
30
|
-
export const HEARTBEAT_TTL_MS =
|
|
32
|
+
export const HEARTBEAT_TTL_MS = 60000
|
|
31
33
|
const REAP_INTERVAL_MS = 5000
|
|
32
34
|
const WATCH_DEBOUNCE_MS = 150
|
|
33
35
|
const STALE_FILE_MS = 24 * 60 * 60 * 1000
|