cardbuddy 0.19.3 → 0.20.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 +7 -7
- package/dist/cli.js +7 -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-08GutEI0.css +1 -0
- package/dist/preview-editor/assets/{index-DRzBK4qj.js → index-Dx5hfJ7f.js} +83 -72
- package/dist/preview-editor/index.html +2 -2
- package/dist/skills-upgrade.js +64 -25
- package/dist/vendored/card-editor/components/MultiColumn.vue +19 -10
- package/dist/vendored/server/core/cardHistoryRemote.js +203 -0
- package/dist/vendored/server/core/cardSync.js +425 -95
- package/dist/vendored/server/core/codeBridge.js +4 -2
- package/dist/vendored/server/core/index.js +5 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +354 -7
- package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +37 -5
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +25 -1
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-C_uqjKqI.css +0 -1
|
@@ -1,113 +1,309 @@
|
|
|
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
|
+
* 任务制模型:POST /__sync 立即返回 jobId,syncCards 在后台跑;
|
|
19
|
+
* 前端经 GET /__sync/progress 轮询进度、POST /__sync/cancel 取消。
|
|
20
|
+
* 保留最近若干个终态任务,供「轮询晚于完成」的前端仍能取到最终结果。
|
|
21
|
+
*/
|
|
22
|
+
const syncJobs = new Map() // jobId -> job
|
|
23
|
+
const SYNC_JOB_KEEP = 8
|
|
24
|
+
let syncJobSeq = 0
|
|
25
|
+
|
|
26
|
+
/** 取当前活跃(未完成)同步任务;无则 null */
|
|
27
|
+
function activeSyncJob() {
|
|
28
|
+
for (const job of syncJobs.values()) {
|
|
29
|
+
if (!job.finished) return job
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 终态任务入表并淘汰最旧的(保持 Map 插入序 = 创建序) */
|
|
35
|
+
function rememberSyncJob(job) {
|
|
36
|
+
syncJobs.set(job.id, job)
|
|
37
|
+
while (syncJobs.size > SYNC_JOB_KEEP) {
|
|
38
|
+
const oldest = syncJobs.keys().next().value
|
|
39
|
+
syncJobs.delete(oldest)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 判断是否为「凭据缺失」类错误(与旧 /__sync 行为一致:转 needCredentials 让前端弹登录表单) */
|
|
44
|
+
function isNoCredentialsError(e) {
|
|
45
|
+
return (
|
|
46
|
+
!!e &&
|
|
47
|
+
(e.code === 'NO_CREDENTIALS' ||
|
|
48
|
+
/credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(String(e && e.message)))
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 同步完成后的终端汇总打印(与旧实现同款美化框,便于 dev server 日志排查) */
|
|
53
|
+
function logSyncSummary(summary, requestLabel) {
|
|
54
|
+
const lines = []
|
|
55
|
+
lines.push(`请求卡片:${requestLabel}`)
|
|
56
|
+
if (summary.synced && summary.synced.length) {
|
|
57
|
+
lines.push(`已同步 ${summary.synced.length} 张:`)
|
|
58
|
+
for (const s of summary.synced) {
|
|
59
|
+
const f = s.files || {}
|
|
60
|
+
// 空白卡(远端无 schemaValue):标注落盘策略,避免误读成「抓到了完整 schema」。
|
|
61
|
+
const tag = s.blank
|
|
62
|
+
? s.keptLocal
|
|
63
|
+
? ' [空白卡·保留本地]'
|
|
64
|
+
: ' [空白卡·新建空白 schema]'
|
|
65
|
+
: ''
|
|
66
|
+
lines.push(
|
|
67
|
+
`• ${s.id}${s.title ? `(${s.title})` : ''}${tag} schema=${fmtBytes(f.schema)} data=${fmtBytes(f.data)} cover=${fmtBytes(f.cover)}`
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
lines.push('已同步 0 张')
|
|
72
|
+
}
|
|
73
|
+
if (summary.failed && summary.failed.length) {
|
|
74
|
+
lines.push(`失败 ${summary.failed.length} 张:`)
|
|
75
|
+
for (const f of summary.failed) {
|
|
76
|
+
lines.push(`✗ ${f.id || ''} ${f.error || ''}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (summary.duplicates && summary.duplicates.length) {
|
|
80
|
+
lines.push(`重复 schema ${summary.duplicates.length} 项:${summary.duplicates.map((d) => d.id).join('、')}`)
|
|
81
|
+
}
|
|
82
|
+
log.box({ title: '远端同步完成', level: summary.success ? 'ok' : 'warn', lines })
|
|
83
|
+
}
|
|
84
|
+
|
|
14
85
|
/**
|
|
15
86
|
* 预览「同步远程数据」按钮的服务端处理:经已认证的 FinMall 浏览器会话拉取最新卡片写回 src/cards。
|
|
16
87
|
* 复用 finmall-card-sync 的 syncCards(connectOverCDP 连用户已开的 Chrome,disconnect 不杀进程)。
|
|
17
88
|
*
|
|
89
|
+
* 任务制端点(编辑器「远端 → 查看全部 → 下载选中 / 全部下载」的进度弹窗依赖):
|
|
90
|
+
* - POST /__sync body { cardCode? , cardCodes? } → 立即返回 { ok, jobId, total }
|
|
91
|
+
* - GET /__sync/progress?jobId → { ok, done, total, currentId, currentTitle, finished, result }
|
|
92
|
+
* - POST /__sync/cancel body { jobId } → 设取消探针,下一张卡片前中止
|
|
93
|
+
* - GET /__remote-cards → { ok, cards:[{id,title,cardId,coverUrl}] }(只读全量列表)
|
|
94
|
+
*
|
|
18
95
|
* @param {{ root: string }} opts
|
|
19
96
|
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
20
97
|
*/
|
|
21
98
|
export function createSyncMiddleware({ root }) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (pathname !== '/__sync') return next()
|
|
25
|
-
if ((req.method || 'GET').toUpperCase() !== 'POST') return next()
|
|
26
|
-
|
|
99
|
+
/** 读 JSON body(上限 1MB,与旧实现一致) */
|
|
100
|
+
const readJsonBody = (req, res, cb) => {
|
|
27
101
|
let body = ''
|
|
28
102
|
req.setEncoding('utf8')
|
|
29
103
|
req.on('data', (c) => {
|
|
30
104
|
body += c
|
|
31
|
-
if (body.length >
|
|
105
|
+
if (body.length > 1024 * 1024) req.destroy()
|
|
32
106
|
})
|
|
33
|
-
req.on('end',
|
|
34
|
-
let payload
|
|
107
|
+
req.on('end', () => {
|
|
35
108
|
try {
|
|
36
|
-
|
|
109
|
+
cb(JSON.parse(body || '{}'))
|
|
37
110
|
} catch (e) {
|
|
38
111
|
res.statusCode = 400
|
|
39
112
|
res.setHeader('Content-Type', 'application/json')
|
|
40
|
-
|
|
113
|
+
res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
|
|
41
114
|
}
|
|
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
|
-
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return function syncMiddleware(req, res, next) {
|
|
119
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
120
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
121
|
+
|
|
122
|
+
// ---- 远端完整卡片列表(只读):编辑器「查看全部」网格数据源 ----
|
|
123
|
+
if (pathname === '/__remote-cards') {
|
|
124
|
+
if (method !== 'GET') return next()
|
|
125
|
+
// 与同步共用后台 Chrome 会话,并发会互相干扰:同步进行中直接拒绝,让前端稍后重试
|
|
126
|
+
if (activeSyncJob()) {
|
|
127
|
+
res.statusCode = 409
|
|
128
|
+
res.setHeader('Content-Type', 'application/json')
|
|
129
|
+
return res.end(
|
|
130
|
+
JSON.stringify({ ok: false, error: 'sync_in_progress', message: '正在拉取卡片,请稍候再查看远端列表' })
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
;(async () => {
|
|
134
|
+
try {
|
|
135
|
+
const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
136
|
+
const { listRemoteCards } = await import(pathToFileURL(skillPath).href)
|
|
137
|
+
const cards = await listRemoteCards({})
|
|
138
|
+
res.statusCode = 200
|
|
139
|
+
res.setHeader('Content-Type', 'application/json')
|
|
140
|
+
return res.end(JSON.stringify({ ok: true, cards }))
|
|
141
|
+
} catch (e) {
|
|
142
|
+
if (isNoCredentialsError(e)) {
|
|
143
|
+
res.statusCode = 200
|
|
144
|
+
res.setHeader('Content-Type', 'application/json')
|
|
145
|
+
return res.end(
|
|
146
|
+
JSON.stringify({
|
|
147
|
+
ok: false,
|
|
148
|
+
error: 'need_credentials',
|
|
149
|
+
needCredentials: true,
|
|
150
|
+
message: String(e && e.message),
|
|
151
|
+
})
|
|
71
152
|
)
|
|
72
153
|
}
|
|
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
|
-
}
|
|
154
|
+
log.error('拉取远端卡片列表失败', e)
|
|
155
|
+
res.statusCode = 500
|
|
156
|
+
res.setHeader('Content-Type', 'application/json')
|
|
157
|
+
return res.end(JSON.stringify({ ok: false, error: 'list failed', message: String(e && e.message) }))
|
|
81
158
|
}
|
|
82
|
-
|
|
83
|
-
|
|
159
|
+
})()
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- 同步进度查询(轮询) ----
|
|
164
|
+
if (pathname === '/__sync/progress') {
|
|
165
|
+
if (method !== 'GET') return next()
|
|
166
|
+
const jobId = new URL(req.url, 'http://127.0.0.1').searchParams.get('jobId') || ''
|
|
167
|
+
const job = syncJobs.get(jobId)
|
|
168
|
+
if (!job) {
|
|
169
|
+
res.statusCode = 404
|
|
170
|
+
res.setHeader('Content-Type', 'application/json')
|
|
171
|
+
return res.end(JSON.stringify({ ok: false, error: 'unknown job', message: '同步任务不存在或已过期' }))
|
|
172
|
+
}
|
|
173
|
+
res.statusCode = 200
|
|
174
|
+
res.setHeader('Content-Type', 'application/json')
|
|
175
|
+
return res.end(
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
ok: true,
|
|
178
|
+
jobId: job.id,
|
|
179
|
+
done: job.progress.done,
|
|
180
|
+
total: job.progress.total,
|
|
181
|
+
currentId: job.progress.currentId,
|
|
182
|
+
currentTitle: job.progress.currentTitle,
|
|
183
|
+
finished: job.finished,
|
|
184
|
+
result: job.result,
|
|
185
|
+
})
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- 取消同步(下一张卡片前生效;已落盘卡片保留) ----
|
|
190
|
+
if (pathname === '/__sync/cancel') {
|
|
191
|
+
if (method !== 'POST') return next()
|
|
192
|
+
return readJsonBody(req, res, (payload) => {
|
|
193
|
+
const jobId = String(payload.jobId || '')
|
|
194
|
+
const job = syncJobs.get(jobId)
|
|
195
|
+
if (!job || job.finished) {
|
|
196
|
+
res.statusCode = 404
|
|
197
|
+
res.setHeader('Content-Type', 'application/json')
|
|
198
|
+
return res.end(JSON.stringify({ ok: false, error: 'unknown job', message: '同步任务不存在或已结束' }))
|
|
84
199
|
}
|
|
85
|
-
|
|
200
|
+
job.cancelRequested = true
|
|
86
201
|
res.statusCode = 200
|
|
87
202
|
res.setHeader('Content-Type', 'application/json')
|
|
88
|
-
return res.end(JSON.stringify(
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
203
|
+
return res.end(JSON.stringify({ ok: true }))
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (pathname !== '/__sync') return next()
|
|
208
|
+
if (method !== 'POST') return next()
|
|
209
|
+
|
|
210
|
+
return readJsonBody(req, res, (payload) => {
|
|
211
|
+
// 入参:单卡 cardCode / 集合 cardCodes(二选一,单卡优先)
|
|
212
|
+
const cardCode =
|
|
213
|
+
typeof payload.cardCode === 'string' && CARD_CODE_RE.test(payload.cardCode)
|
|
214
|
+
? payload.cardCode
|
|
215
|
+
: undefined
|
|
216
|
+
let cardCodes
|
|
217
|
+
if (Array.isArray(payload.cardCodes)) {
|
|
218
|
+
const set = new Set(
|
|
219
|
+
payload.cardCodes.filter((c) => typeof c === 'string' && CARD_CODE_RE.test(c))
|
|
220
|
+
)
|
|
221
|
+
if (set.size > 500) {
|
|
222
|
+
res.statusCode = 400
|
|
223
|
+
res.setHeader('Content-Type', 'application/json')
|
|
224
|
+
return res.end(
|
|
225
|
+
JSON.stringify({ ok: false, error: 'too_many', message: '单次下载勾选不能超过 500 张' })
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
if (set.size) cardCodes = [...set]
|
|
229
|
+
}
|
|
230
|
+
if (!cardCode && !cardCodes && payload.cardCode != null) {
|
|
231
|
+
// 传了 cardCode 但不合法:明确拒绝,避免静默变成全量
|
|
232
|
+
res.statusCode = 400
|
|
98
233
|
res.setHeader('Content-Type', 'application/json')
|
|
99
234
|
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
|
-
),
|
|
235
|
+
JSON.stringify({ ok: false, error: 'invalid cardCode', message: 'cardCode 含有非法字符' })
|
|
109
236
|
)
|
|
110
237
|
}
|
|
238
|
+
// 同一时刻只允许一个同步任务(全量同步动辄数分钟,并发会互相打断)
|
|
239
|
+
if (activeSyncJob()) {
|
|
240
|
+
res.statusCode = 409
|
|
241
|
+
res.setHeader('Content-Type', 'application/json')
|
|
242
|
+
return res.end(
|
|
243
|
+
JSON.stringify({ ok: false, error: 'sync_in_progress', message: '已有同步任务正在进行,请等待完成或取消' })
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
syncJobSeq += 1
|
|
248
|
+
const job = {
|
|
249
|
+
id: `sj-${Date.now().toString(36)}-${syncJobSeq}`,
|
|
250
|
+
createdAt: Date.now(),
|
|
251
|
+
cancelRequested: false,
|
|
252
|
+
finished: false,
|
|
253
|
+
progress: { done: 0, total: cardCode ? 1 : cardCodes ? cardCodes.length : 0, currentId: '', currentTitle: '' },
|
|
254
|
+
result: null,
|
|
255
|
+
}
|
|
256
|
+
rememberSyncJob(job)
|
|
257
|
+
|
|
258
|
+
// 后台跑同步:请求立即返回,前端轮询 progress
|
|
259
|
+
;(async () => {
|
|
260
|
+
try {
|
|
261
|
+
const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
262
|
+
const { syncCards } = await import(pathToFileURL(skillPath).href)
|
|
263
|
+
const summary = await syncCards({
|
|
264
|
+
cardCode,
|
|
265
|
+
cardCodes,
|
|
266
|
+
outRoot: path.join(root, 'src', 'cards'),
|
|
267
|
+
// 从 server/core 上下文注入 cardImportMeta 实现:消费者工程下 api_batch_sync.mjs 按自身
|
|
268
|
+
// skills 位置推导的 workspace 会指向错误根,动态 import 找不到 cardImportMeta.js。
|
|
269
|
+
importMeta: { buildImportMeta, writeImportMeta },
|
|
270
|
+
// 从 server/core 上下文注入 cardListCache 实现:同 cardImportMeta,消费者工程下按自身 skills
|
|
271
|
+
// 位置推导的 workspace 会指向错误根,动态 import 找不到 cardListCache.js,导致全量同步时无法
|
|
272
|
+
// 刷新进程内单例,推送前「存在性闸门」形同虚设。注入后三态都能正确刷新。
|
|
273
|
+
cardListCache: { refreshCardList, checkPushAllowed, hasCardId },
|
|
274
|
+
onProgress: (p) => {
|
|
275
|
+
job.progress = {
|
|
276
|
+
done: Number(p.done) || 0,
|
|
277
|
+
total: p.total == null ? job.progress.total : Number(p.total) || 0,
|
|
278
|
+
currentId: p.currentId || '',
|
|
279
|
+
currentTitle: p.currentTitle || '',
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
shouldCancel: () => job.cancelRequested,
|
|
283
|
+
})
|
|
284
|
+
job.result = {
|
|
285
|
+
success: !!summary.success,
|
|
286
|
+
cancelled: !!summary.cancelled,
|
|
287
|
+
synced: summary.synced || [],
|
|
288
|
+
failed: summary.failed || [],
|
|
289
|
+
duplicates: summary.duplicates || [],
|
|
290
|
+
}
|
|
291
|
+
logSyncSummary(summary, cardCode ? cardCode : cardCodes ? `勾选 ${cardCodes.length} 张` : '(全部)')
|
|
292
|
+
} catch (e) {
|
|
293
|
+
if (isNoCredentialsError(e)) {
|
|
294
|
+
job.result = { needCredentials: true, message: String(e && e.message) }
|
|
295
|
+
} else {
|
|
296
|
+
job.result = { success: false, error: String(e && e.message) }
|
|
297
|
+
log.error('远端同步失败', e)
|
|
298
|
+
}
|
|
299
|
+
} finally {
|
|
300
|
+
job.finished = true
|
|
301
|
+
}
|
|
302
|
+
})()
|
|
303
|
+
|
|
304
|
+
res.statusCode = 200
|
|
305
|
+
res.setHeader('Content-Type', 'application/json')
|
|
306
|
+
return res.end(JSON.stringify({ ok: true, jobId: job.id, total: job.progress.total }))
|
|
111
307
|
})
|
|
112
308
|
}
|
|
113
309
|
}
|
|
@@ -190,10 +386,110 @@ export function createPreviewMiddleware({ root }) {
|
|
|
190
386
|
}
|
|
191
387
|
}
|
|
192
388
|
|
|
389
|
+
/** finmall-session-navigator 登录脚本(凭据读写 + 页面内登录验证),相对 skills/ */
|
|
390
|
+
const SESSION_LOGIN_REL = 'finmall-session-navigator/scripts/finmall_login.mjs'
|
|
391
|
+
/** finmall-session-navigator 后台会话管理(同步 / 登录验证共用同一 headless Chrome) */
|
|
392
|
+
const SESSION_MANAGER_REL = 'finmall-session-navigator/scripts/session-manager.mjs'
|
|
393
|
+
|
|
394
|
+
/** 凭据落盘路径:与 finmall_login.mjs 默认路径同址,但经 projectSkillsDir 永远写项目根 skills/,不进 node_modules */
|
|
395
|
+
function credentialsFilePath(root) {
|
|
396
|
+
return path.join(
|
|
397
|
+
projectSkillsDir(root),
|
|
398
|
+
'finmall-session-navigator',
|
|
399
|
+
'.local',
|
|
400
|
+
'credentials.json',
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** 动态加载 finmall-session-navigator 登录脚本(仓库内 / 消费者工程 / 包内快照三态通用) */
|
|
405
|
+
async function loadLoginModule(root) {
|
|
406
|
+
const p = resolveSkillPathOrProject(root, SESSION_LOGIN_REL)
|
|
407
|
+
return import(pathToFileURL(p).href)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 读取已落盘凭据的登录名;缺失 / 损坏 / 格式不合法一律返回 null(不返回密码哈希) */
|
|
411
|
+
async function readSavedLoginName(root) {
|
|
412
|
+
try {
|
|
413
|
+
const { loadCredentials } = await loadLoginModule(root)
|
|
414
|
+
const creds = await loadCredentials(credentialsFilePath(root))
|
|
415
|
+
return creds.loginName || null
|
|
416
|
+
} catch {
|
|
417
|
+
return null
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
193
421
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
422
|
+
* 用给定账号密码做一次真实登录验证(不落盘)。
|
|
423
|
+
* 与「同步」共用同一后台 headless Chrome 会话池:连上 / 拉起后在 FinMall 同源页面内
|
|
424
|
+
* 直调登录接口(performLogin),以是否建立会话(sessionCreated)判定凭据是否正确——
|
|
425
|
+
* 已登录会话不会蒙混过关,因为performLogin 每次都真实提交一遍账号密码。
|
|
426
|
+
* 只 disconnect 不关闭浏览器:后台 Chrome 以 detached 常驻,供后续同步复用。
|
|
427
|
+
*
|
|
428
|
+
* @returns {Promise<{ sessionCreated: boolean, businessFailure: boolean, status?: number }>}
|
|
429
|
+
*/
|
|
430
|
+
async function verifyFinmallLogin(root, loginName, rawPassword) {
|
|
431
|
+
const {
|
|
432
|
+
performLogin,
|
|
433
|
+
tryConnectBrowser,
|
|
434
|
+
launchChromeDetached,
|
|
435
|
+
findChromePath,
|
|
436
|
+
waitForDebugPort,
|
|
437
|
+
TARGET_URL,
|
|
438
|
+
} = await loadLoginModule(root)
|
|
439
|
+
const managerPath = resolveSkillPathOrProject(root, SESSION_MANAGER_REL)
|
|
440
|
+
const { SYNC_PORT, SYNC_BROWSER_DATA_DIR } = await import(
|
|
441
|
+
pathToFileURL(managerPath).href,
|
|
442
|
+
)
|
|
443
|
+
const md5Hex = createHash('md5')
|
|
444
|
+
.update(String(rawPassword), 'utf8')
|
|
445
|
+
.digest('hex')
|
|
446
|
+
|
|
447
|
+
let browser = await tryConnectBrowser(SYNC_PORT)
|
|
448
|
+
if (!browser) {
|
|
449
|
+
// 端口未启动:拉起后台 headless Chrome(与 ensureFinMallSession 同端口同数据目录)
|
|
450
|
+
launchChromeDetached(findChromePath(), SYNC_BROWSER_DATA_DIR, SYNC_PORT, TARGET_URL, {
|
|
451
|
+
headless: true,
|
|
452
|
+
})
|
|
453
|
+
const ready = await waitForDebugPort(SYNC_PORT, 20000)
|
|
454
|
+
if (!ready) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`后台 Chrome 调试端口 ${SYNC_PORT} 未就绪,无法完成登录验证(检查 Chrome 是否安装、端口是否被占用)`,
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
for (let i = 0; i < 5 && !browser; i += 1) {
|
|
460
|
+
browser = await tryConnectBrowser(SYNC_PORT)
|
|
461
|
+
if (!browser) await new Promise((r) => setTimeout(r, 400))
|
|
462
|
+
}
|
|
463
|
+
if (!browser) {
|
|
464
|
+
throw new Error(`无法连接后台 Chrome 调试端口 ${SYNC_PORT},无法完成登录验证`)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const context = browser.contexts()[0]
|
|
469
|
+
let page = context?.pages().find((p) => p.url().includes('finmall.com'))
|
|
470
|
+
if (!page) {
|
|
471
|
+
page = context ? await context.newPage() : await browser.newPage()
|
|
472
|
+
await page
|
|
473
|
+
.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 20000 })
|
|
474
|
+
.catch(() => {})
|
|
475
|
+
}
|
|
476
|
+
return await performLogin(page, { loginName, loginPassword: md5Hex })
|
|
477
|
+
} finally {
|
|
478
|
+
try {
|
|
479
|
+
await browser.disconnect()
|
|
480
|
+
} catch {
|
|
481
|
+
/* 后台 Chrome 常驻,仅断开连接 */
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* 登录凭据端点(顶栏「远端」的前置门禁):
|
|
488
|
+
* - GET /__login-credentials 查询凭据是否已落盘 → { ok, hasCredentials, loginName }
|
|
489
|
+
* - POST /__login-credentials 提交账号 + 明文密码:先做一次真实登录验证,通过才落盘
|
|
490
|
+
* (MD5,不落明文);账号 / 密码错误返回 ok:false 且不落盘,由前端保留弹窗让用户重填。
|
|
491
|
+
*
|
|
492
|
+
* 旧行为(不验证直接写盘)会把错误密码持久化,且重跑也不再重问——故改为「验证通过才落盘」。
|
|
197
493
|
*
|
|
198
494
|
* @param {{ root: string }} opts
|
|
199
495
|
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
@@ -202,7 +498,30 @@ export function createLoginCredentialsMiddleware({ root }) {
|
|
|
202
498
|
return function loginCredentialsMiddleware(req, res, next) {
|
|
203
499
|
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
204
500
|
if (pathname !== '/__login-credentials') return next()
|
|
205
|
-
|
|
501
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
502
|
+
if (method !== 'GET' && method !== 'POST') return next()
|
|
503
|
+
res.setHeader('Content-Type', 'application/json')
|
|
504
|
+
|
|
505
|
+
if (method === 'GET') {
|
|
506
|
+
readSavedLoginName(root)
|
|
507
|
+
.then((loginName) => {
|
|
508
|
+
res.statusCode = 200
|
|
509
|
+
res.end(
|
|
510
|
+
JSON.stringify({
|
|
511
|
+
ok: true,
|
|
512
|
+
hasCredentials: !!loginName,
|
|
513
|
+
loginName: loginName || '',
|
|
514
|
+
}),
|
|
515
|
+
)
|
|
516
|
+
})
|
|
517
|
+
.catch((e) => {
|
|
518
|
+
res.statusCode = 500
|
|
519
|
+
res.end(
|
|
520
|
+
JSON.stringify({ error: 'status failed', message: String(e && e.message) }),
|
|
521
|
+
)
|
|
522
|
+
})
|
|
523
|
+
return
|
|
524
|
+
}
|
|
206
525
|
|
|
207
526
|
let body = ''
|
|
208
527
|
req.setEncoding('utf8')
|
|
@@ -216,7 +535,6 @@ export function createLoginCredentialsMiddleware({ root }) {
|
|
|
216
535
|
payload = JSON.parse(body || '{}')
|
|
217
536
|
} catch (e) {
|
|
218
537
|
res.statusCode = 400
|
|
219
|
-
res.setHeader('Content-Type', 'application/json')
|
|
220
538
|
return res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
|
|
221
539
|
}
|
|
222
540
|
const loginName =
|
|
@@ -225,7 +543,6 @@ export function createLoginCredentialsMiddleware({ root }) {
|
|
|
225
543
|
typeof payload.loginPassword === 'string' ? payload.loginPassword : ''
|
|
226
544
|
if (!loginName || !rawPassword) {
|
|
227
545
|
res.statusCode = 400
|
|
228
|
-
res.setHeader('Content-Type', 'application/json')
|
|
229
546
|
return res.end(
|
|
230
547
|
JSON.stringify({
|
|
231
548
|
error: 'missing_fields',
|
|
@@ -234,28 +551,41 @@ export function createLoginCredentialsMiddleware({ root }) {
|
|
|
234
551
|
)
|
|
235
552
|
}
|
|
236
553
|
try {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
554
|
+
// 1) 先做一次真实登录验证,通过才落盘
|
|
555
|
+
const result = await verifyFinmallLogin(root, loginName, rawPassword)
|
|
556
|
+
if (result.sessionCreated) {
|
|
557
|
+
const { saveCredentials } = await loadLoginModule(root)
|
|
558
|
+
await saveCredentials(loginName, rawPassword, credentialsFilePath(root))
|
|
559
|
+
log.ok('登录验证通过,已保存登录凭据', { loginName })
|
|
560
|
+
res.statusCode = 200
|
|
561
|
+
return res.end(JSON.stringify({ ok: true, verified: true, loginName }))
|
|
562
|
+
}
|
|
563
|
+
if (result.businessFailure) {
|
|
564
|
+
// 账号 / 密码错误:不落盘,前端保留弹窗让用户重新输入
|
|
565
|
+
log.warn('登录验证未通过:账号或密码错误', { loginName })
|
|
566
|
+
res.statusCode = 200
|
|
567
|
+
return res.end(
|
|
568
|
+
JSON.stringify({
|
|
569
|
+
ok: false,
|
|
570
|
+
error: 'invalid_credentials',
|
|
571
|
+
message: '账号或密码错误,登录验证未通过(凭据未保存)',
|
|
572
|
+
}),
|
|
573
|
+
)
|
|
247
574
|
}
|
|
248
|
-
await mkdir(path.dirname(credentialsPath), { recursive: true })
|
|
249
|
-
await writeFile(credentialsPath, JSON.stringify(credentials, null, 2), 'utf8')
|
|
250
|
-
log.ok('已保存登录凭据', { loginName })
|
|
251
575
|
res.statusCode = 200
|
|
252
|
-
res.
|
|
253
|
-
|
|
576
|
+
return res.end(
|
|
577
|
+
JSON.stringify({
|
|
578
|
+
ok: false,
|
|
579
|
+
error: 'verify_failed',
|
|
580
|
+
message: `登录验证未通过(HTTP ${result.status || '未知'}),凭据未保存`,
|
|
581
|
+
}),
|
|
582
|
+
)
|
|
254
583
|
} catch (e) {
|
|
255
|
-
log.error('
|
|
584
|
+
log.error('登录验证失败', e)
|
|
256
585
|
res.statusCode = 500
|
|
257
|
-
res.
|
|
258
|
-
|
|
586
|
+
return res.end(
|
|
587
|
+
JSON.stringify({ error: 'verify_failed', message: String(e && e.message) }),
|
|
588
|
+
)
|
|
259
589
|
}
|
|
260
590
|
})
|
|
261
591
|
}
|
|
@@ -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
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { createSyncMiddleware, createPreviewMiddleware, createLoginCredentialsMiddleware } from './cardSync.js'
|
|
14
14
|
import { createPublishMiddleware } from './cardPublish.js'
|
|
15
15
|
import { createCardVersionMiddleware } from './cardVersion.js'
|
|
16
|
+
import { createCardHistoryMiddleware } from './cardHistoryRemote.js'
|
|
16
17
|
import { createSliceUploadMiddleware } from './sliceUpload.js'
|
|
17
18
|
import { createCardFilesMiddleware, refreshCardList } from './cardFiles.js'
|
|
18
19
|
import { createSaveMiddleware } from './cardSave.js'
|
|
@@ -46,6 +47,7 @@ export {
|
|
|
46
47
|
createLoginCredentialsMiddleware,
|
|
47
48
|
createPublishMiddleware,
|
|
48
49
|
createCardVersionMiddleware,
|
|
50
|
+
createCardHistoryMiddleware,
|
|
49
51
|
createSliceUploadMiddleware,
|
|
50
52
|
createCardFilesMiddleware,
|
|
51
53
|
createSaveMiddleware,
|
|
@@ -98,6 +100,9 @@ export function mountCoreMiddlewares(app, { root, viteServer = null, upstream =
|
|
|
98
100
|
// 卡片来源 tab 的「远端最新版本」查询(只读):GET /__card-version/:id 取 IDC10004 的 maxVersion,
|
|
99
101
|
// 复用 finmall-card-sync 的 fetchCardLatestVersion,与发布/同步/保存共用同一后台 FinMall 会话。
|
|
100
102
|
app.use(createCardVersionMiddleware({ root }))
|
|
103
|
+
// 「卡片历史记录」tab 的远端版本时间线(只读):GET /__card-history/:cardCode 取 IDC10013 的
|
|
104
|
+
// schemaValueList(新→旧,含预览图 / 变更标签),复用 finmall-card-sync 的 fetchCardHistory。
|
|
105
|
+
app.use(createCardHistoryMiddleware({ root }))
|
|
101
106
|
// 切图上传(顶栏「远端 → 切图上传」):把当前卡引用的 src/slices 切图传到 FinMall 图片库,
|
|
102
107
|
// 写 src/slices/manifest.json 映射并把卡片里的 /slices/ 引用改写为 @filestore/ 线上地址。
|
|
103
108
|
// 仅在用户点击时触发,没有任何自动上传路径。
|