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.
Files changed (29) hide show
  1. package/README.md +8 -8
  2. package/dist/cli.js +21 -8
  3. package/dist/init.js +3 -3
  4. package/dist/preview-editor/assets/codeBridgeHeartbeat.worker-Zb_A5RUd.js +1 -0
  5. package/dist/preview-editor/assets/index-C8VLzU18.css +1 -0
  6. package/dist/preview-editor/assets/index-CpDojF__.js +383 -0
  7. package/dist/preview-editor/index.html +2 -2
  8. package/dist/skills-upgrade.js +64 -25
  9. package/dist/standalone.js +49 -0
  10. package/dist/vendored/card-editor/components/MultiColumn.vue +19 -10
  11. package/dist/vendored/card-editor/runtime/card-runtime.js +1 -1
  12. package/dist/vendored/mcp/client.mjs +22 -3
  13. package/dist/vendored/server/core/ai-chat.js +92 -12
  14. package/dist/vendored/server/core/cardExport.js +831 -0
  15. package/dist/vendored/server/core/cardHistoryRemote.js +203 -0
  16. package/dist/vendored/server/core/cardImportMeta.js +6 -1
  17. package/dist/vendored/server/core/cardPublishVersions.js +91 -0
  18. package/dist/vendored/server/core/cardSave.js +143 -6
  19. package/dist/vendored/server/core/cardSync.js +467 -95
  20. package/dist/vendored/server/core/codeBridge.js +4 -2
  21. package/dist/vendored/server/core/index.js +16 -0
  22. package/dist/vendored/skills/finmall-card-sync/SKILL.md +46 -0
  23. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +657 -15
  24. package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +79 -8
  25. package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +25 -1
  26. package/dist/vendored/skills/mbank-design-gen/tools/screenshot.mjs +10 -1
  27. package/package.json +1 -1
  28. package/dist/preview-editor/assets/index-C_uqjKqI.css +0 -1
  29. 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
- return function syncMiddleware(req, res, next) {
23
- const pathname = decodeURIComponent((req.url || '').split('?')[0])
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 > 1 * 1024 * 1024) req.destroy()
118
+ if (body.length > 1024 * 1024) req.destroy()
32
119
  })
33
- req.on('end', async () => {
34
- let payload
120
+ req.on('end', () => {
35
121
  try {
36
- payload = JSON.parse(body || '{}')
122
+ cb(JSON.parse(body || '{}'))
37
123
  } catch (e) {
38
124
  res.statusCode = 400
39
125
  res.setHeader('Content-Type', 'application/json')
40
- return res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
126
+ res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
41
127
  }
42
- try {
43
- const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
44
- const { syncCards } = await import(pathToFileURL(skillPath).href)
45
- const summary = await syncCards({
46
- cardCode: payload.cardCode || undefined,
47
- outRoot: path.join(root, 'src', 'cards'),
48
- // 从 server/core 上下文注入 cardImportMeta 实现:消费者工程下 api_batch_sync.mjs 按自身
49
- // skills 位置推导的 workspace 会指向错误根,动态 import 找不到 cardImportMeta.js。
50
- importMeta: { buildImportMeta, writeImportMeta },
51
- // 从 server/core 上下文注入 cardListCache 实现:同 cardImportMeta,消费者工程下按自身 skills
52
- // 位置推导的 workspace 会指向错误根,动态 import 找不到 cardListCache.js,导致全量同步时无法
53
- // 刷新进程内单例,推送前「存在性闸门」形同虚设。注入后三态都能正确刷新。
54
- cardListCache: { refreshCardList, checkPushAllowed, hasCardId },
55
- })
56
- // 同步成功后在 dev server 终端打印同步结果(统一美化框)。
57
- const lines = []
58
- lines.push(`请求卡片:${payload.cardCode || '(全部)'}`)
59
- if (summary.synced && summary.synced.length) {
60
- lines.push(`已同步 ${summary.synced.length} 张:`)
61
- for (const s of summary.synced) {
62
- const f = s.files || {}
63
- // 空白卡(远端无 schemaValue):标注落盘策略,避免误读成「抓到了完整 schema」。
64
- const tag = s.blank
65
- ? s.keptLocal
66
- ? ' [空白卡·保留本地]'
67
- : ' [空白卡·新建空白 schema]'
68
- : ''
69
- lines.push(
70
- `• ${s.id}${s.title ? `(${s.title})` : ''}${tag} schema=${fmtBytes(f.schema)} data=${fmtBytes(f.data)} cover=${fmtBytes(f.cover)}`
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
- } else {
74
- lines.push('已同步 0 张')
75
- }
76
- if (summary.failed && summary.failed.length) {
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
- if (summary.duplicates && summary.duplicates.length) {
83
- lines.push(`重复 schema ${summary.duplicates.length} 项:${summary.duplicates.map((d) => d.id).join('、')}`)
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
- log.box({ title: '远端同步完成', level: summary.success ? 'ok' : 'warn', lines })
213
+ job.cancelRequested = true
86
214
  res.statusCode = 200
87
215
  res.setHeader('Content-Type', 'application/json')
88
- return res.end(JSON.stringify(summary))
89
- } catch (e) {
90
- // 区分「凭据缺失」与「真正的同步错误」:前者让前端弹登录表单,而非裸抛 ENOENT。
91
- const isNoCred =
92
- e &&
93
- (e.code === 'NO_CREDENTIALS' ||
94
- /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(
95
- String(e && e.message),
96
- ))
97
- res.statusCode = isNoCred ? 200 : 500
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
- * 服务端做 MD5 后写入 skills/finmall-session-navigator/.local/credentials.json。
196
- * 路径与 finmall_login.mjs 的 DEFAULT_CREDENTIALS_PATH 保持一致。
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
- if ((req.method || 'GET').toUpperCase() !== 'POST') return next()
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
- const credentialsPath = path.join(
238
- root,
239
- 'skills',
240
- 'finmall-session-navigator',
241
- '.local',
242
- 'credentials.json',
243
- )
244
- const credentials = {
245
- loginName,
246
- loginPassword: createHash('md5').update(rawPassword, 'utf8').digest('hex'),
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.setHeader('Content-Type', 'application/json')
253
- return res.end(JSON.stringify({ ok: true }))
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('保存登录凭据失败', e)
626
+ log.error('登录验证失败', e)
256
627
  res.statusCode = 500
257
- res.setHeader('Content-Type', 'application/json')
258
- return res.end(JSON.stringify({ error: 'save failed', message: String(e && e.message) }))
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 心跳,20s 无心跳即删文件,覆盖关浏览器 / 崩溃);启动时清扫 >24h 残留。
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 = 20000
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