cardbuddy 0.21.0 → 0.22.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.
@@ -8,8 +8,8 @@
8
8
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16-Cg6Nrosk.png" />
9
9
  <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32-B_1744sp.png" />
10
10
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-BQcES9QK.png" />
11
- <script type="module" crossorigin src="/assets/index-CpDojF__.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-C8VLzU18.css">
11
+ <script type="module" crossorigin src="/assets/index-CPpLZZyi.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-CU8zVA2P.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="app"></div>
@@ -15,17 +15,20 @@ import { resolveCardDir } from './cardOpenFolder.js'
15
15
  * <targetDir>/<yyMMddHHmmss+4位序号>/ ← 临时文件夹(完成后删除)
16
16
  * cards.json ← 卡片清单(cardPO 数组,殿后写入)
17
17
  * <数字id>/<数字id>.json ← { cardPO, cardJobPO, variablePOS, imageLists, platformStatus }
18
- * <数字id>/<封面文件> ← 封面,文件名恒等于 cards.json 里该卡 picUrl 的末段
19
- * <数字id>/image/<imageName> ← schema.view 树引用的 @filestore 图片下载落盘
18
+ * <数字id>/<cardCode><ext> ← 封面,文件名 === cards.json 里 picUrl 末段(见「封面命名铁律」)
19
+ * <数字id>/image/<imageName> ← schema.view 树引用的 @filestore 图片下载落盘(沿用原始名)
20
20
  * <targetDir>/<同名>.zip ← 纯 Node 写出的 zip(条目平铺、无顶层文件夹)
21
21
  *
22
- * 封面铁律(2026-10-22 事故后定,依据 57 张官方导出样本 + ImportCardAction.java:61-64):
23
- * - picUrl 恒非空,且末段 === `<数字id>/` 下封面文件名。导入侧按 picUrl 末段推文件名后直接读该文件,
24
- * 对所有空值/缺失零防御(空串时路径退化成卡目录,读目录抛异常)。
25
- * - 无本地封面的卡**不允许导出**(计入 failed 并给出补齐指引),不产「有卡无封面」的残包。
26
- * - 有 `.import-meta.json` 的 remote 快照时沿用远端真值(picUrl/cardClassId/applyScene);
27
- * 本地新建卡按同一形态合成 picUrl,cardClassId 留空、applyScene 回落 '1'。
28
- * - 写 zip 前跑 verifyCardsJsonInvariants 自检,违例即整单失败。
22
+ * 封面命名铁律(2026-10-26 四期事故后定,四次事故复盘见 docs §9.1~§9.4):
23
+ * 封面文件名 = `<cardCode><封面扩展名>`,cardPO.picUrl = `@filestore/dzqd-download/<同上文件名>`
24
+ * —— cardCode 是本地卡片目录名(= cardPO.code,也是同步/真机链路的一等身份标识),天然只含
25
+ * `[A-Za-z0-9_-]`,对**任何** percent-encode 口径(Java URLEncoder / JS encodeURIComponent /
26
+ * 后端「不编码直接字面比较」)都还原自身 ⇒ picUrl 末段、磁盘封面名、zip 封面条目名三处**字面相等**,
27
+ * 不再依赖双方编码器互相猜。
28
+ * 前四期事故(① picUrl 置空 ② 中文名编码不一致 ③ `@`/括号 JS 与 Java 口径不同 ④ 空格名)全部同源:
29
+ * 「JSON 里保留用户原始封面名、由包内外各自编码推导磁盘名」。规范化命名一次性根除该类型。
30
+ * 作用域只到封面:`image/` 与 imageLists 仍沿用 view 树引用的原始名 + 既有 percent-encode 条目口径。
31
+ * 写 zip 前跑 verifyCardsJsonInvariants / verifyZipEntryNames 两道自检,违例即整单失败。
29
32
  * 任务制端点(与 /__sync 同构:单飞制 + 看门狗 + 轮询 + 取消):
30
33
  * - POST /__export-cards body { cardIds[], targetDir } → { ok, jobId, total }
31
34
  * - GET /__export/progress?jobId → { ok, done, total, currentId, currentTitle, finished, result }
@@ -317,28 +320,164 @@ function refBasename(ref) {
317
320
  * picUrl 为空串时路径退化成卡目录本身,读目录抛异常,整卡导入失败。
318
321
  * 故此处绝不允许产出空 picUrl。
319
322
  *
320
- * @param {{ remotePicUrl?: string, coverPath: string }} o
321
- * remotePicUrl:`.import-meta.json` 记录的远端真值(有则原样沿用,形态与官方产物同构);
322
- * coverPath:本地封面绝对路径(无远端真值时以其文件名为准)。
323
- * @returns {{ fileName: string, picUrl: string, inherited: boolean }}
323
+ * 文件名不再继承远端 / 用户上传的原始名(四期事故:中文 / 空格 / `@` / 括号四次都因两侧
324
+ * 编码口径不一致而对不上),统一取 `<资源基名><扩展名>`(资源基名 = cardCode,见 resolveAssetBase)
325
+ * —— 任何编码口径都还原自身。
326
+ *
327
+ * @param {{ assetBase: string, coverPath: string }} o
328
+ * assetBase:资源命名基名(cardCode,异常时回落数字 id);coverPath:本地封面绝对路径(只取扩展名)。
329
+ * @returns {{ fileName: string, picUrl: string }}
330
+ */
331
+ export function resolveCoverForExport({ assetBase, coverPath }) {
332
+ const fileName = coverExportName(assetBase, path.extname(coverPath))
333
+ return { fileName, picUrl: `${COVER_FILESTORE_PREFIX}/${fileName}` }
334
+ }
335
+
336
+ /** percent-encode 的保留字符集:与 Java `URLEncoder` 的「无需编码」集合同口径(`*` 也保留) */
337
+ const ENC_SAFE_CH = /[A-Za-z0-9\-_.*]/
338
+ /**
339
+ * 按 **Java `URLEncoder.encode(x, "UTF-8")` 口径**做 percent-encode(空格例外,见下)。
340
+ *
341
+ * 为什么不用内置 `encodeURIComponent`:两者的保留集合不同,JS 会放过 `! ' ( ) ~`,
342
+ * Java 会全部编码。2026-10-26 事故的封面名 `20260615151313_WPS图片(1).png` 正好踩在
343
+ * `(` `)` 上:JS 口径产出 `...%E5%9B%BE%E7%89%87(1).png`,后端按自己的口径算出
344
+ * `...%E5%9B%BE%E7%89%87%281%29.png`,两边对不上 → 找不到封面文件 → 导入失败。
345
+ *
346
+ * 空格:Java 原生编码成 `+`,而官方 57 卡样本里 `电话备份 2@3x.png` 落地是 `%20`,
347
+ * 故按 `+`→`%20` 归一(`%20` 在两种解码口径下都还原成空格,`+` 只在 URL 表单口径下还原)。
348
+ * @param {string} str 原始文本
349
+ * @returns {string}
350
+ */
351
+ export function javaUrlEncode(str) {
352
+ let out = ''
353
+ for (const ch of String(str)) {
354
+ if (ENC_SAFE_CH.test(ch)) {
355
+ out += ch
356
+ } else if (ch === ' ') {
357
+ out += '%20'
358
+ } else {
359
+ for (const b of Buffer.from(ch, 'utf8')) out += '%' + b.toString(16).toUpperCase().padStart(2, '0')
360
+ }
361
+ }
362
+ return out
363
+ }
364
+
365
+ /* ------------------------- 包内资源命名(2026-10-26 四期:一律以卡片 id 生成) ------------------------- */
366
+
367
+ /** 扩展名归一:只认已知图片扩展名(小写),其余或缺失回落 `.png` */
368
+ export function normalizeImageExt(ext, fallback = '.png') {
369
+ const e = String(ext || '').toLowerCase()
370
+ return IMAGE_EXTS.has(e) ? e : fallback
371
+ }
372
+
373
+ /**
374
+ * 「编码无关」文件名判定:该名字在**任何** percent-encode 口径下都还原自身。
375
+ * 只有纯 `[A-Za-z0-9._-]` 成立 —— 中文、空格、`@`、`(` `)`、`%`、`~`、`!` 在任一口径下都会变形。
376
+ * 这是四次事故的总闸:名字符合此判定 ⇒ JSON 里的值与磁盘名/zip 条目名不可能对不上。
377
+ * @param {string} name
378
+ * @returns {boolean}
379
+ */
380
+ export function isInvariantName(name) {
381
+ const s = String(name || '')
382
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(s) && javaUrlEncode(s) === s
383
+ }
384
+
385
+ /** 封面文件名:`<资源基名><ext>`(卡目录根,与 `<数字id>.json` 同级,彼此不撞名) */
386
+ export function coverExportName(base, ext) {
387
+ return `${base}${normalizeImageExt(ext)}`
388
+ }
389
+
390
+ /**
391
+ * 资源命名基名:**用 cardCode(= 本地卡片目录名 = cardPO.code)**,不用用户上传 / 远端原始文件名。
392
+ * cardCode 是本仓对卡片的一等身份标识(`-cardCode mostlyLike` 同步、侧车 `cardCode`、真机 `alertCard({cardCode})`
393
+ * 全部以此为准),且天然只含 `[A-Za-z0-9_-]`(`cardSync` 的 `CARD_CODE_RE` 同口径校验),可直接做文件名。
394
+ * 兜底:个别目录名万一不合「编码无关」判定,回落到数字 id 并告警 —— 宁可用数字也不用不可推导的名字。
395
+ * @param {{ cardCode: string, numericId: string, onFallback?: (msg: string) => void }} o
396
+ * @returns {string}
397
+ */
398
+ export function resolveAssetBase({ cardCode, numericId, onFallback }) {
399
+ const code = String(cardCode || '')
400
+ if (isInvariantName(code)) return code
401
+ if (typeof onFallback === 'function') {
402
+ onFallback(`cardCode 不合规范化字符集(${code}),资源名回落到数字 id ${numericId}`)
403
+ }
404
+ return String(numericId)
405
+ }
406
+
407
+ /**
408
+ * zip 条目名转 ASCII:**JSON 保留原名,包内文件名 percent-encode**。
409
+ *
410
+ * 契约依据(官方 57 卡大样本 `2609091710000055.zip`:439 个条目里非 ASCII 为 0):
411
+ * 中文名整段编码——`20260519101312_右箭头@3x.png` 落地为
412
+ * `20260519101312_%E5%8F%B3%E7%AE%AD%E5%A4%B4%403x.png`(`@`→`%40`、空格→`%20`),
413
+ * 而 `cards.json`/`<id>.json` 里的 `picUrl`、`imageName`、`localUrl` 全部保持中文原文。
414
+ * 该口径与 `javaUrlEncode()` 对 439 个官方条目**逐字节一致**(除去老工具写出的 `????` 乱码副本)。
415
+ *
416
+ * 为什么必须这样(2026-10-23 事故):后端(Java)读 zip 条目名不认 UTF-8 语言编码标志位,
417
+ * 非 ASCII 名会被按默认字符集解成乱码 → 解压落盘的文件名与 JSON 里引用的名字对不上 →
418
+ * 导入报错。上一版只落中文原名(旧 §8.1「只落原名一份」)正是这次失败的直接原因。
419
+ *
420
+ * **所有段无条件编码**(不是「命中中文才编码」):官方样本 `20260522173828_VS-44@3x.png`
421
+ * 整段无中文,仍落地为 `20260522173828_VS-44%403x.png`。纯 ASCII 名里的 `@`/空格/括号/`#`
422
+ * 同样要编(`greetingRecommend` 的 `mascotDing@2x.png`、`activityList` 的 `...图片(1).png`)。
423
+ *
424
+ * 幂等:已编码段(本地切图名可能本来就是 %XX 形式)先 decode 再 encode,避免二次编码;
425
+ * 含裸 `%` 无法解码时按原文编码(`%`→`%25`)。
426
+ * @param {string} rel 相对路径(POSIX 分隔符)
427
+ * @returns {string}
324
428
  */
325
- export function resolveCoverForExport({ remotePicUrl, coverPath }) {
326
- const localName = path.basename(coverPath)
327
- const remote = typeof remotePicUrl === 'string' ? remotePicUrl.trim() : ''
328
- if (remote) {
329
- // 远端真值可能带 query(极少见),取文件名时剥掉;picUrl 本身保持原样不重写
330
- const name = path.basename(remote.split('?')[0].split('#')[0])
331
- if (name) return { fileName: name, picUrl: remote, inherited: true }
429
+ export function zipEntryName(rel) {
430
+ return String(rel)
431
+ .split('/')
432
+ .map((seg) => {
433
+ let raw = seg
434
+ try {
435
+ raw = decodeURIComponent(seg)
436
+ } catch {
437
+ /* 含裸 % / 非法转义:直接按原文编码 */
438
+ }
439
+ return javaUrlEncode(raw)
440
+ })
441
+ .join('/')
442
+ }
443
+
444
+ /**
445
+ * zip 层自检(与后端无关的结构断言):
446
+ * 1. 条目名全 ASCII(官方 439/439 全 ASCII;非 ASCII 名会被后端按错误字符集解成乱码);
447
+ * image/ 下的图仍沿用原始名,由 `zipEntryName()` 编码成 %XX 形态落条目(见 §4.2)。
448
+ * 2. 无重名(同卡两个不同文件编码后撞名会让其中一份被覆盖);
449
+ * 3. 每张卡的封面以「picUrl 末段」的条目名存在(封面名已是规范化名,`zipEntryName` 对其为恒等变换)。
450
+ * @param {{ cardsPO: Array<object>, zipNames: string[] }} o
451
+ * @returns {string[]}
452
+ */
453
+ export function verifyZipEntryNames({ cardsPO, zipNames }) {
454
+ const problems = []
455
+ const names = zipNames || []
456
+ for (const n of names) {
457
+ if (/[^\x00-\x7F]/.test(n)) problems.push(`条目名非 ASCII:${n}`)
332
458
  }
333
- return { fileName: localName, picUrl: `${COVER_FILESTORE_PREFIX}/${localName}`, inherited: false }
459
+ const seen = new Set()
460
+ for (const n of names) {
461
+ if (seen.has(n)) problems.push(`条目名重复:${n}`)
462
+ seen.add(n)
463
+ }
464
+ for (const po of cardsPO || []) {
465
+ if (!po || !po.picUrl) continue
466
+ const id = String(po.id)
467
+ const cover = path.basename(String(po.picUrl).split('?')[0])
468
+ const expect = zipEntryName(`${id}/${cover}`)
469
+ if (!seen.has(expect)) problems.push(`${id}: 封面条目缺失 ${expect}`)
470
+ }
471
+ return problems
334
472
  }
335
473
 
336
474
  /**
337
475
  * 产物自检:cards.json 的每条 cardPO 都能被导入侧无障碍消费。
338
476
  * 只做**与后端无关**的结构断言(不依赖远端服务,纯本地即可证伪):
339
477
  * 1. picUrl 非空;
340
- * 2. picUrl 末段在 `<id>/` 卡目录根存在且是文件(不是目录);
341
- * 3. 每张卡都有 `<id>/<id>.json`。
478
+ * 2. picUrl 末段**编码无关**(否则 backend 按自己的编码口径推文件名必然错位);
479
+ * 3. picUrl 末段在 `<id>/` 卡目录根存在且是文件(不是目录);
480
+ * 4. 每张卡都有 `<id>/<id>.json`。
342
481
  * 返回问题清单(空数组 = 通过)。任何一条违例都会被导入侧的同一段逻辑打出异常。
343
482
  * @param {{ cardsPO: Array<object>, outDir: string }} o
344
483
  * @returns {string[]}
@@ -353,12 +492,16 @@ export function verifyCardsJsonInvariants({ cardsPO, outDir }) {
353
492
  continue
354
493
  }
355
494
  const name = path.basename(String(picUrl).split('?')[0])
356
- const coverAbs = path.join(outDir, id, name)
495
+ // 末段必须是「编码无关」的生成名:含中文/空格/@/括号时,两侧编码口径一旦不同就对不上(四期同源事故)
496
+ if (!isInvariantName(name)) {
497
+ problems.push(`${id}: picUrl 末段含需转义字符 ${name}(封面名须为纯 [A-Za-z0-9._-])`)
498
+ }
357
499
  let stat = null
358
500
  try {
359
- stat = fs.statSync(coverAbs)
501
+ const s = fs.statSync(path.join(outDir, id, name))
502
+ if (!stat) stat = s
360
503
  } catch {
361
- /* 不存在即视为违例 */
504
+ /* 不存在即报错 */
362
505
  }
363
506
  if (!stat) problems.push(`${id}: 封面文件缺失 ${name}`)
364
507
  else if (!stat.isFile()) problems.push(`${id}: 封面路径不是文件 ${name}`)
@@ -402,11 +545,15 @@ async function exportOneCard({ root, id, outDir, upstream, buildPreview }) {
402
545
  '无封面必导入失败。请先在卡片上「生成封面预览图」补齐,再重试导出'
403
546
  )
404
547
  }
548
+ const warnings = []
405
549
  const remoteMeta = (meta && meta.remote) || null
406
- const cover = resolveCoverForExport({
407
- remotePicUrl: remoteMeta && remoteMeta.picUrl,
408
- coverPath,
550
+ // 资源名基名 = cardCode(本地目录名 = cardPO.code);不再 Ping-pong 用户/远端的原始文件名与编码口径
551
+ const assetBase = resolveAssetBase({
552
+ cardCode: id,
553
+ numericId,
554
+ onFallback: (msg) => warnings.push(msg),
409
555
  })
556
+ const cover = resolveCoverForExport({ assetBase, coverPath })
410
557
 
411
558
  const cardPO = {
412
559
  id: numericId,
@@ -416,8 +563,8 @@ async function exportOneCard({ root, id, outDir, upstream, buildPreview }) {
416
563
  updateTime: null,
417
564
  sassAppId: (meta && meta.appId) || '100001',
418
565
  sassWorkspaceId: (meta && meta.workspaceId) || 'dev',
419
- // picUrl 恒非空,且末段 === 卡目录根封面文件名(见 resolveCoverForExport 的契约说明):
420
- // 有远端真值(侧车 remote.picUrl)就原样沿用,与官方产物同构;本地新建卡按同一形态合成。
566
+ // picUrl 恒非空,且末段 === 卡目录根封面文件名(见 resolveCoverForExport 的契约说明)。
567
+ // 名字由数字 id 生成、编码无关,picUrl 末段 / 磁盘名 / zip 条目名三者字面相等,无需任何转义推导。
421
568
  picUrl: cover.picUrl,
422
569
  // 远端列表才有的分类字段:有真值用真值(官方 57 张样本里恒非空),未知时留空由导入侧兜底
423
570
  cardClassId: (remoteMeta && remoteMeta.cardClassId) || '',
@@ -463,6 +610,7 @@ async function exportOneCard({ root, id, outDir, upstream, buildPreview }) {
463
610
  updateTime: null,
464
611
  }))
465
612
 
613
+ // imageName/localUrl 沿用 view 树引用的原始末段(本次不改图片口径,见 §9.4)
466
614
  const refs = collectViewImageRefs(schema.view)
467
615
  const imageLists = refs.map(({ ref }) => {
468
616
  const imageName = refBasename(ref)
@@ -477,9 +625,8 @@ async function exportOneCard({ root, id, outDir, upstream, buildPreview }) {
477
625
  // 落盘:<outDir>/<numericId>/
478
626
  const cardOutDir = path.join(outDir, numericId)
479
627
  fs.mkdirSync(cardOutDir, { recursive: true })
480
- const warnings = []
481
628
 
482
- // 图片:@filestore 走 minio 公网下载;/slices/ 从 src/slices 拷贝
629
+ // 图片:@filestore 走 minio 公网下载;/slices/ 从 src/slices 拷贝(落盘名沿用原 ImageName)
483
630
  for (const { ref, kind } of refs) {
484
631
  const imageName = refBasename(ref)
485
632
  if (!imageName) continue
@@ -487,7 +634,7 @@ async function exportOneCard({ root, id, outDir, upstream, buildPreview }) {
487
634
  try {
488
635
  fs.mkdirSync(path.dirname(dest), { recursive: true })
489
636
  if (kind === 'slice') {
490
- const src = path.join(root, 'src', 'slices', imageName)
637
+ const src = path.join(root, 'src', 'slices', refBasename(ref))
491
638
  if (!fs.existsSync(src)) throw new Error('本地切图不存在')
492
639
  fs.copyFileSync(src, dest)
493
640
  } else {
@@ -512,9 +659,6 @@ async function exportOneCard({ root, id, outDir, upstream, buildPreview }) {
512
659
 
513
660
  // 封面:以 picUrl 末段的文件名落卡目录根(导入侧按此名取文件;官方产物同形)
514
661
  fs.copyFileSync(coverPath, path.join(cardOutDir, cover.fileName))
515
- if (!cover.inherited) {
516
- warnings.push(`本地无远端 picUrl,封面已合成 ${cover.picUrl}(文件名为本地预览图名)`)
517
- }
518
662
 
519
663
  const cardJson = JSON.stringify({
520
664
  cardPO,
@@ -607,13 +751,15 @@ async function runExportJob(job, { root, cardIds, targetDir, upstream, viteServe
607
751
  )
608
752
  }
609
753
 
610
- // 收集 zip 条目(按名排序保证确定性),cards.json 殿后(与样本一致)
754
+
755
+ // 收集 zip 条目(按名排序保证确定性),cards.json 殿后(与样本一致)。
756
+ // 条目名一律经 zipEntryName 转 ASCII:JSON 里保留原名,包内文件名 percent-encode。
611
757
  const collectEntries = (dir, prefix) => {
612
758
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
613
759
  const full = path.join(dir, entry.name)
614
760
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name
615
761
  if (entry.isDirectory()) collectEntries(full, rel)
616
- else zipEntries.push({ name: rel, data: fs.readFileSync(full) })
762
+ else zipEntries.push({ name: zipEntryName(rel), data: fs.readFileSync(full) })
617
763
  }
618
764
  }
619
765
  collectEntries(outDir, '')
@@ -621,6 +767,18 @@ async function runExportJob(job, { root, cardIds, targetDir, upstream, viteServe
621
767
  fs.writeFileSync(path.join(outDir, 'cards.json'), JSON.stringify(cardsPO), 'utf8')
622
768
  zipEntries.push({ name: 'cards.json', data: Buffer.from(JSON.stringify(cardsPO), 'utf8') })
623
769
 
770
+ // zip 层自检:条目名全 ASCII(对齐官方 439/439)、无重名、每张卡的封面条目按编码名命中
771
+ const zipProblems = verifyZipEntryNames({
772
+ cardsPO,
773
+ zipNames: zipEntries.map((e) => e.name),
774
+ })
775
+ if (zipProblems.length) {
776
+ throw new Error(
777
+ `导出产物 zip 自检未通过(${zipProblems.length} 项):${zipProblems.slice(0, 5).join(';')}` +
778
+ (zipProblems.length > 5 ? ' 等' : '')
779
+ )
780
+ }
781
+
624
782
  const zipPath = path.join(targetDir, `${folderName}.zip`)
625
783
  fs.writeFileSync(zipPath, buildZip(zipEntries))
626
784
 
@@ -0,0 +1,190 @@
1
+ import { execFile } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ /**
6
+ * 卡片发布后的「本地快照提交」:只提交该卡片目录下的变更,不 push、不触碰其他改动。
7
+ *
8
+ * 设计约束(与仓库 AGENTS.md 的提交规范一致):
9
+ * - 提交类型固定用 `chore(cards)`:发布快照属日常维护,按 version-bump 映射不升版本号;
10
+ * - 只提交 `src/cards/<code>/` 这一个 pathspec:`git commit --only -- <path>` 会忽略索引里
11
+ * 其他已暂存的内容,绝不把用户正在做的别处改动一起带走;
12
+ * - `.import-meta.json` 等被 .gitignore 忽略的文件由 git 自行跳过,无需特判;
13
+ * - 永不 push、永不抛错:远端发布已经成功,本地提交失败只作为附加信息回传,不影响主流程;
14
+ * - 失败时不 reset 索引:宁可留下已暂存状态,也不清掉用户原本的暂存意图。
15
+ *
16
+ * 「工程不是 git 工程」是正常场景(npm 包 / 独立部署副本 / 拷进别的仓库子目录都会没有
17
+ * 自己的 .git),因此这条链路必须全程静默降级:识别为不可提交就返回 skipped 类 reason,
18
+ * 既不写日志噪声,也不给用户弹错误提示。
19
+ */
20
+
21
+ const GIT_TIMEOUT_MS = 20000
22
+
23
+ /** 模块级串行队列:连续两次发布避免并发争抢 .git/index.lock */
24
+ let chain = Promise.resolve()
25
+
26
+ /**
27
+ * 跑一条 git 命令(不抛错,统一返回结果对象)。
28
+ * @param {string} cwd
29
+ * @param {string[]} args
30
+ * @returns {Promise<{ok: boolean, stdout: string, stderr: string, code: string}>}
31
+ */
32
+ function runGit(cwd, args) {
33
+ return new Promise((resolve) => {
34
+ execFile(
35
+ 'git',
36
+ args,
37
+ {
38
+ cwd,
39
+ timeout: GIT_TIMEOUT_MS,
40
+ maxBuffer: 4 * 1024 * 1024,
41
+ // 禁用任何交互(凭据 / 编辑器),避免服务进程被 git 挂住
42
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_EDITOR: 'true', GIT_PAGER: 'cat' },
43
+ },
44
+ (err, stdout, stderr) => {
45
+ resolve({
46
+ ok: !err,
47
+ stdout: String(stdout || ''),
48
+ stderr: String(stderr || ''),
49
+ // ENOENT = 机器上没装 git / 不在 PATH;killed = 超时被杀
50
+ code: String((err && (err.code || (err.killed ? 'ETIMEDOUT' : ''))) || ''),
51
+ })
52
+ },
53
+ )
54
+ })
55
+ }
56
+
57
+ /** 相对路径统一成 posix 形式(git pathspec 在 Windows 上同样接受正斜杠) */
58
+ function toPosixRel(root, target) {
59
+ const rel = path.relative(root, target)
60
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return ''
61
+ return rel.split(path.sep).join('/')
62
+ }
63
+
64
+ /**
65
+ * 路径归一:解析软链后比较,避免 /var 与 /private/var、符号链接目录导致的误判。
66
+ * 解析失败(目录已删等)退回 path.resolve。
67
+ */
68
+ function canonical(p) {
69
+ try {
70
+ return fs.realpathSync(p)
71
+ } catch {
72
+ return path.resolve(p)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 判断 root 是否为「自己的」git 仓库根。
78
+ *
79
+ * 只认 root 本身就是仓库顶层(`rev-parse --show-toplevel` == root)这一种情况:
80
+ * - 不是仓库 → not_a_git_repo(工程未纳入版本控制,正常场景);
81
+ * - 位于某个父仓库内部(例如把工程拷进 home 目录仓库 / 某个巨仓子目录)→ 也算
82
+ * not_a_git_repo,绝不能把卡片提交到那个不相干的父仓库里去;
83
+ * - git 命令不存在 / 超时 → git_unavailable。
84
+ *
85
+ * @returns {Promise<{ok: boolean, reason?: string, message?: string}>}
86
+ */
87
+ async function detectOwnRepo(root) {
88
+ const ver = await runGit(root, ['--version'])
89
+ if (!ver.ok) {
90
+ return {
91
+ ok: false,
92
+ reason: 'git_unavailable',
93
+ message: ver.code === 'ENOENT' ? '未检测到 git 命令' : 'git 命令执行失败',
94
+ }
95
+ }
96
+ const top = await runGit(root, ['rev-parse', '--show-toplevel'])
97
+ if (!top.ok) {
98
+ return { ok: false, reason: 'not_a_git_repo', message: '工程目录不是 git 仓库' }
99
+ }
100
+ if (canonical(top.stdout.trim()) !== canonical(root)) {
101
+ return {
102
+ ok: false,
103
+ reason: 'not_a_git_repo',
104
+ message: '工程目录不是独立的 git 仓库根(位于其他仓库内部),已跳过提交',
105
+ }
106
+ }
107
+ return { ok: true }
108
+ }
109
+
110
+ /**
111
+ * 提交某卡片目录的未提交变更(不 push)。
112
+ *
113
+ * @param {{ root: string, cardDir: string|null, cardCode: string, version?: string|number }} opts
114
+ * @returns {Promise<{ committed: boolean, reason?: string, message?: string, hash?: string, files?: string[] }>}
115
+ * reason 取值:card_dir_not_found / not_a_git_repo / git_unavailable / no_changes / failed
116
+ */
117
+ export function commitCardChanges({ root, cardDir, cardCode, version }) {
118
+ const task = chain.then(() => doCommit({ root, cardDir, cardCode, version }))
119
+ // 队列自身吞掉异常,保证后续任务不被前一个失败拖垮
120
+ chain = task.catch(() => {})
121
+ return task
122
+ }
123
+
124
+ async function doCommit({ root, cardDir, cardCode, version }) {
125
+ try {
126
+ if (!cardDir) {
127
+ return { committed: false, reason: 'card_dir_not_found', message: '未定位到卡片本地目录' }
128
+ }
129
+ const rel = toPosixRel(root, cardDir)
130
+ if (!rel) {
131
+ return { committed: false, reason: 'card_dir_not_found', message: '卡片目录不在工程根内' }
132
+ }
133
+
134
+ const repo = await detectOwnRepo(root)
135
+ if (!repo.ok) {
136
+ // 工程不是 git 工程(含 git 不可用)属正常场景:静默跳过,不产生任何提示
137
+ return { committed: false, reason: repo.reason, message: repo.message }
138
+ }
139
+
140
+ // 该卡目录是否有未提交变更(含未跟踪文件;被忽略的文件不计入,符合预期)
141
+ const status = await runGit(root, ['status', '--porcelain', '--', rel])
142
+ if (!status.ok) {
143
+ return { committed: false, reason: 'failed', message: status.stderr.trim() || 'git status 失败' }
144
+ }
145
+ if (!status.stdout.trim()) {
146
+ return { committed: false, reason: 'no_changes' }
147
+ }
148
+
149
+ const add = await runGit(root, ['add', '-A', '--', rel])
150
+ if (!add.ok) {
151
+ return { committed: false, reason: 'failed', message: add.stderr.trim() || 'git add 失败' }
152
+ }
153
+
154
+ // add 之后仍可能为空(例如只改动了被 .gitignore 忽略的文件)
155
+ const staged = await runGit(root, ['diff', '--cached', '--name-only', '--', rel])
156
+ const files = staged.ok
157
+ ? staged.stdout
158
+ .split('\n')
159
+ .map((s) => s.trim())
160
+ .filter(Boolean)
161
+ : []
162
+ if (!files.length) {
163
+ return { committed: false, reason: 'no_changes' }
164
+ }
165
+
166
+ // 提交类型固定 chore:发布快照属日常维护,不触发版本号升级(映射见 tools/version-bump.mjs)
167
+ const ver = version == null || String(version).trim() === '' ? '' : ` v${version}`
168
+ const message = `chore(cards): ${cardCode} 发布${ver ? `${ver} 后` : '后'}提交本地变更`
169
+ // --only + pathspec:只提交该卡目录的工作区内容,索引里其他已暂存改动原样保留
170
+ const commit = await runGit(root, ['commit', '--only', '-m', message, '--', rel])
171
+ if (!commit.ok) {
172
+ return {
173
+ committed: false,
174
+ reason: 'failed',
175
+ message: (commit.stderr || commit.stdout).trim() || 'git commit 失败',
176
+ files,
177
+ }
178
+ }
179
+
180
+ const head = await runGit(root, ['rev-parse', '--short', 'HEAD'])
181
+ return {
182
+ committed: true,
183
+ hash: head.ok ? head.stdout.trim() : '',
184
+ message,
185
+ files,
186
+ }
187
+ } catch (e) {
188
+ return { committed: false, reason: 'failed', message: String((e && e.message) || e) }
189
+ }
190
+ }
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path'
2
2
  import { pathToFileURL } from 'node:url'
3
- import { assertRemoteSource } from './cardSidecar.js'
3
+ import { assertRemoteSource, findCardDir } from './cardSidecar.js'
4
+ import { commitCardChanges } from './cardGitCommit.js'
4
5
  import { checkPushAllowed } from './cardListCache.js'
5
6
  import { log } from './logger.js'
6
7
  import { resolveSkillPathOrProject } from './skillPaths.js'
@@ -20,6 +21,10 @@ const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
20
21
  * 避免把测试数据误发布到 FinMall 服务器。
21
22
  * - checkPushAllowed:卡片须存在于远端列表缓存(全量同步时刷新);缓存为空(尚未同步)则放行。
22
23
  *
24
+ * 发布成功后的收尾:把该卡目录(src/cards/<code>/)的未提交变更提交为一条
25
+ * `chore(cards)` 提交(见 cardGitCommit.js),只落本地、不 push。提交结果随响应
26
+ * 以 commit 字段回传,前端据此提示;提交失败不回滚发布,也不影响接口成功态。
27
+ *
23
28
  * @param {{ root: string }} opts
24
29
  * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
25
30
  */
@@ -72,9 +77,38 @@ export function createPublishMiddleware({ root }) {
72
77
  const { publishCard } = await import(pathToFileURL(skillPath).href)
73
78
  const result = await publishCard(payload.schema)
74
79
  log.ok('卡片发布完成', { cardId: payload.schema.pageId, nextVersion: result.nextVersion })
80
+
81
+ // 发布成功后的本地快照:该卡目录若有未提交变更,只提交这一个目录(不 push)。
82
+ // 提交的是磁盘现状——编辑器未保存的改动不在其中,故失败/跳过都不影响发布结果。
83
+ // 整段独立 try/catch:工程不是 git 工程、git 未安装、定位目录异常等一律静默降级,
84
+ // 绝不能让附加的本地提交把已经成功的发布改成 500。
85
+ let commit = { committed: false, reason: 'skipped', message: '本地提交环节已跳过' }
86
+ try {
87
+ const cardDir = findCardDir(root, { pageId: payload.schema.pageId })
88
+ const cardCode = cardDir ? path.basename(cardDir) : String(payload.schema.pageId)
89
+ commit = await commitCardChanges({ root, cardDir, cardCode, version: result.nextVersion })
90
+ if (commit.committed) {
91
+ log.ok('发布后已提交卡片本地变更', { cardCode, hash: commit.hash, files: commit.files })
92
+ } else if (commit.reason === 'failed') {
93
+ log.warn('发布后提交卡片本地变更失败', { cardCode, message: commit.message })
94
+ }
95
+ // 其余 reason(not_a_git_repo / git_unavailable / no_changes / card_dir_not_found)
96
+ // 都是正常的「无需提交」,不写日志、不提示用户。
97
+ } catch (e) {
98
+ commit = { committed: false, reason: 'skipped', message: String((e && e.message) || e) }
99
+ log.warn('本地提交环节异常(已忽略,不影响发布)', { message: String((e && e.message) || e) })
100
+ }
101
+
75
102
  res.statusCode = 200
76
103
  res.setHeader('Content-Type', 'application/json')
77
- return res.end(JSON.stringify({ ok: true, maxVersion: result.maxVersion, nextVersion: result.nextVersion }))
104
+ return res.end(
105
+ JSON.stringify({
106
+ ok: true,
107
+ maxVersion: result.maxVersion,
108
+ nextVersion: result.nextVersion,
109
+ commit,
110
+ }),
111
+ )
78
112
  } catch (e) {
79
113
  log.error('卡片发布失败', e)
80
114
  // 区分「凭据缺失」与「真正的发布错误」:前者让前端弹登录表单,而非裸抛 ENOENT。