@andyqiu/codeforge 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/install.mjs CHANGED
@@ -176,8 +176,15 @@ const MANAGED_DIRS = ["codeforge", "agents", "commands", "workflows", "context-t
176
176
  // 细粒度卸载:只删 CodeForge 自己的 skill
177
177
  const OWNED_SKILLS = ["ambiguity-gate", "devils-advocate", "ears-zh", "example-mapping", "success-criteria", "weighted-dimensions"]
178
178
 
179
- // 文件级 copy(.md,排除 README/_*/.bak/隐藏)
179
+ // 文件级 copy(.md,排除 README/_*/.bak/隐藏)— per-file upsert + manifest 差集清理,不 rmrf 整目录
180
+ // ADR:install-whitelist-md-files
180
181
  const MD_COPY_DIRS = [["agents", "agents"], ["commands", "commands"]]
182
+
183
+ // codeforge 自维护的 .md 安装清单:记录"上一版 codeforge 在各目标目录装了哪些 .md"。
184
+ // 用途:install 时算差集(manifest − 本次源)精准清理本版已删除的旧 codeforge 文件,
185
+ // 第三方 .md(如 teamkit-git.md)从不写入 manifest → 永不被清理(零副作用)。
186
+ // 位置在 codeforge/ 子目录下,uninstall 的 rmrf(codeforge/) 会自然清掉,无需额外卸载逻辑。
187
+ const MD_MANIFEST_REL = "codeforge/installed-md-manifest.json"
181
188
  // 整目录拷贝
182
189
  const COPY_DIRS = [
183
190
  ["workflows", "workflows"],
@@ -450,31 +457,75 @@ function installBundle({ targetRoot, bundleSrc }) {
450
457
  vok(`VERSION → ${versionFile} (${version})`)
451
458
  }
452
459
 
460
+ // ADR:install-whitelist-md-files —— manifest 自维护:不再 rmrf 整目录,
461
+ // 读 manifest 算差集精准清理旧 codeforge .md,第三方 .md 零副作用。
453
462
  function installMdDirs({ targetRoot }) {
463
+ // 步骤 1:读上一版 manifest(不存在 → 各目录空列表 = 首次安装)
464
+ const prevManifest = readMdManifest(targetRoot)
465
+ const nextManifest = {}
466
+
454
467
  for (const [srcName, dstName] of MD_COPY_DIRS) {
455
468
  const srcPath = path.join(SOURCE_ROOT, srcName)
456
469
  const dstPath = path.join(targetRoot, dstName)
457
470
  if (!fs.existsSync(srcPath) || !fs.statSync(srcPath).isDirectory()) {
458
471
  vlog(`源目录不存在,跳过: ${srcPath}`)
472
+ // 源目录缺失:本版该目录装 0 个文件,manifest 记空(差集会清掉上一版全部该目录文件)
473
+ nextManifest[dstName] = []
474
+ const prevList = prevManifest[dstName] ?? []
475
+ pruneStale({ dstPath, prevList, sourceSet: new Set() })
459
476
  continue
460
477
  }
461
- if (fs.existsSync(dstPath) || isSymlink(dstPath)) rmrf(dstPath)
462
- ensureDir(dstPath)
463
- let count = 0
464
- const entries = DRY_RUN ? safeReaddir(srcPath) : fs.readdirSync(srcPath)
465
- for (const base of entries) {
478
+ ensureDir(dstPath) // 不再 rmrf;幂等建目录
479
+
480
+ // 步骤 2:扫源目录 → sourceSet(本版要装的文件名集合)
481
+ const sourceFiles = []
482
+ const srcEntries = fs.readdirSync(srcPath)
483
+ for (const base of srcEntries) {
466
484
  if (!shouldCopyMd(base)) continue
467
- const f = path.join(srcPath, base)
468
- try {
469
- if (!fs.statSync(f).isFile()) continue
470
- } catch {
471
- continue
485
+ try { if (!fs.statSync(path.join(srcPath, base)).isFile()) continue } catch { continue }
486
+ sourceFiles.push(base)
487
+ }
488
+ const sourceSet = new Set(sourceFiles)
489
+
490
+ // 步骤 3:差集清理 prevManifest[dir] − sourceSet(仅删确实存在的)
491
+ const prevList = prevManifest[dstName] ?? []
492
+ const pruned = pruneStale({ dstPath, prevList, sourceSet })
493
+
494
+ // 步骤 4:upsert —— 把 sourceSet 文件复制到目标(覆盖同名,不动其他文件)
495
+ // ADR:install-manifest-actual-copied-consistency —— manifest 记录实际落盘(事实),非源扫描意图
496
+ let count = 0
497
+ const copiedOk = [] // 仅含 post-copy existsSync 复核通过的文件名
498
+ if (!DRY_RUN) {
499
+ // 真实 copy:收集 copiedOk,逐文件复核,复核失败 fail-loud
500
+ for (const base of sourceFiles) {
501
+ const src = path.join(srcPath, base)
502
+ const dst = path.join(dstPath, base)
503
+ fs.copyFileSync(src, dst)
504
+ if (!fs.existsSync(dst)) {
505
+ // copy 后文件仍不存在:可能是 FS 异常(NFS/虚拟 FS 等),fail-loud
506
+ throw new Error(`copy 后文件不存在(existsSync 复核失败):${dst}`)
507
+ }
508
+ copiedOk.push(base)
509
+ count++
510
+ }
511
+ } else {
512
+ // DRY_RUN:run() 已 vlog,不执行 copy,copiedOk 保持空(dry-run 不落盘)
513
+ for (const base of sourceFiles) {
514
+ vlog(`[dry-run] cp ${path.join(srcPath, base)} ${dstPath}/`)
515
+ count++
472
516
  }
473
- run(`cp ${f} ${dstPath}/`, () => fs.copyFileSync(f, path.join(dstPath, base)))
474
- count++
475
517
  }
476
- vok(`${srcName}/ → ${dstPath} (${count} 个 .md)`)
518
+
519
+ // 步骤 5:新 manifest = copiedOk(实际落盘文件,非 sourceFiles 意图)
520
+ // DRY_RUN 下 copiedOk 为空,由 run() 包裹的 writeMdManifest 负责 vlog 不落盘
521
+ nextManifest[dstName] = DRY_RUN ? [...sourceFiles] : copiedOk
522
+
523
+ vok(`${srcName}/ → ${dstPath} (${count} 个 .md upsert, ${pruned} 个过期清理, 第三方文件保留)`)
477
524
  }
525
+
526
+ // 步骤 5(落盘):写新 manifest
527
+ ensureDir(path.join(targetRoot, "codeforge")) // 幂等,保证父目录存在
528
+ run(`write md manifest → ${MD_MANIFEST_REL}`, () => writeMdManifest(targetRoot, nextManifest))
478
529
  }
479
530
 
480
531
  function safeReaddir(p) {
@@ -485,6 +536,125 @@ function safeReaddir(p) {
485
536
  }
486
537
  }
487
538
 
539
+ // 读 manifest:不存在 / 解析失败 → 返回各目录空列表(首次安装走此分支)。
540
+ // 返回结构:{ [dstName]: string[] },含 MD_COPY_DIRS 的所有 dstName 键。
541
+ // 损坏 JSON 保守降级为"首次安装"(差集空 → 不误删)。
542
+ function readMdManifest(targetRoot) {
543
+ const empty = {}
544
+ for (const [, dstName] of MD_COPY_DIRS) empty[dstName] = []
545
+ const p = path.join(targetRoot, MD_MANIFEST_REL)
546
+ let raw
547
+ try {
548
+ raw = fs.readFileSync(p, "utf8")
549
+ } catch {
550
+ return empty // 文件不存在 → 首次安装
551
+ }
552
+ try {
553
+ const parsed = JSON.parse(raw)
554
+ const out = {}
555
+ for (const [, dstName] of MD_COPY_DIRS) {
556
+ const v = parsed?.[dstName]
557
+ out[dstName] = Array.isArray(v) ? v.filter((x) => typeof x === "string") : []
558
+ }
559
+ return out
560
+ } catch {
561
+ return empty // 损坏的 manifest → 视为首次安装(保守:差集为空,不误删)
562
+ }
563
+ }
564
+
565
+ // 写 manifest:atomicWriteJson(同目录 tmp + rename)。data 形如 { agents: [...], commands: [...] }。
566
+ // 调用方需保证 codeforge/ 目录已存在(installBundle 先于 installMdDirs 跑,已建 codeforge/)。
567
+ function writeMdManifest(targetRoot, data) {
568
+ const p = path.join(targetRoot, MD_MANIFEST_REL)
569
+ atomicWriteJson(p, data)
570
+ }
571
+
572
+ // compareManifestVsDisk:三方比对(manifest / disk / source)
573
+ // ADR:codeforge-doctor-command — doctor 与 install 共享的纯函数
574
+ // 返回:
575
+ // missing[] = manifest 登记但 disk 无(来源2:外部腐蚀)
576
+ // unmanaged[] = source 有(经 shouldCopyMd 过滤)但 disk 无(来源1/2:未安装 / 被删)
577
+ // stale[] = disk 有 & source 有 但 manifest 无(manifest 失配,可能未更新)
578
+ // 第三方文件(disk 有但不在 manifest 也不在 source)→ 不纳入任何桶(静默跳过)
579
+ function compareManifestVsDisk({ targetRoot, sourceRoot }) {
580
+ const manifest = readMdManifest(targetRoot)
581
+ const missing = []
582
+ const unmanaged = []
583
+ const stale = []
584
+
585
+ for (const [srcName, dstName] of MD_COPY_DIRS) {
586
+ const srcPath = path.join(sourceRoot, srcName)
587
+ const dstPath = path.join(targetRoot, dstName)
588
+
589
+ // source 扫描(经 shouldCopyMd 过滤)
590
+ const sourceFiles = new Set()
591
+ try {
592
+ const entries = fs.readdirSync(srcPath)
593
+ for (const base of entries) {
594
+ if (!shouldCopyMd(base)) continue
595
+ try { if (!fs.statSync(path.join(srcPath, base)).isFile()) continue } catch { continue }
596
+ sourceFiles.add(base)
597
+ }
598
+ } catch {
599
+ // 源目录不存在(发布包未含该目录)→ sourceFiles 为空,正常走后续逻辑
600
+ }
601
+
602
+ // manifest 登记列表
603
+ const manifestList = manifest[dstName] ?? []
604
+ const manifestSet = new Set(manifestList)
605
+
606
+ // disk 实际文件(目录不存在时视为空)
607
+ const diskSet = new Set()
608
+ try {
609
+ const diskEntries = fs.readdirSync(dstPath)
610
+ for (const base of diskEntries) {
611
+ try { if (!fs.statSync(path.join(dstPath, base)).isFile()) continue } catch { continue }
612
+ diskSet.add(base)
613
+ }
614
+ } catch {
615
+ // 目录不存在 → diskSet 为空
616
+ }
617
+
618
+ // D1:manifest 登记但 disk 无
619
+ for (const base of manifestList) {
620
+ if (!diskSet.has(base)) {
621
+ missing.push({ dir: dstName, file: base })
622
+ }
623
+ }
624
+
625
+ // D2:source 有但 disk 无(未安装或被删)
626
+ for (const base of sourceFiles) {
627
+ if (!diskSet.has(base)) {
628
+ unmanaged.push({ dir: dstName, file: base })
629
+ }
630
+ }
631
+
632
+ // D3:disk 有 & source 有 但 manifest 无(manifest 失配)
633
+ for (const base of diskSet) {
634
+ if (sourceFiles.has(base) && !manifestSet.has(base)) {
635
+ stale.push({ dir: dstName, file: base })
636
+ }
637
+ }
638
+ }
639
+
640
+ return { missing, unmanaged, stale }
641
+ }
642
+
643
+ // 差集清理:prevList − sourceSet,仅删目标目录里确实存在的文件。返回清理计数。
644
+ // 第三方文件(如 teamkit-git.md)从不写入 manifest → 不在 prevList → 永不被删。
645
+ function pruneStale({ dstPath, prevList, sourceSet }) {
646
+ let pruned = 0
647
+ const actual = new Set(safeReaddir(dstPath))
648
+ for (const base of prevList) {
649
+ if (sourceSet.has(base)) continue // 本版仍发布 → 保留
650
+ if (!actual.has(base)) continue // 目标里已不存在 → 无需删
651
+ const stale = path.join(dstPath, base)
652
+ run(`rm ${stale}(过期 codeforge .md)`, () => fs.rmSync(stale, { force: true }))
653
+ pruned++
654
+ }
655
+ return pruned
656
+ }
657
+
488
658
  function installCopyDirs({ targetRoot }) {
489
659
  for (const [srcName, dstName] of COPY_DIRS) {
490
660
  const srcPath = path.join(SOURCE_ROOT, srcName)
@@ -742,6 +912,11 @@ export {
742
912
  LEGACY_DIRS,
743
913
  MANAGED_DIRS,
744
914
  OWNED_SKILLS,
915
+ readMdManifest,
916
+ writeMdManifest,
917
+ MD_MANIFEST_REL,
918
+ compareManifestVsDisk,
919
+ MD_COPY_DIRS,
745
920
  }
746
921
 
747
922
  // main 守卫:仅作为脚本直接运行时执行
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andyqiu/codeforge",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "CodeForge — opencode 的零侵入扩展包",
5
5
  "type": "module",
6
6
  "private": false,