cardbuddy 0.19.0 → 0.19.2

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.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * `cardbuddy skills upgrade` —— 把已装 cardbuddy 包内最新的技能库同步进当前工程的 skills/。
3
+ *
4
+ * 背景:init 时把 dist/vendored/skills 复制进 <root>/skills/(增量幂等,见 init.js)。
5
+ * 运行时 skillPaths 解析「项目 skills/ 优先于包内快照」(server/core/skillPaths.js),
6
+ * 故只 `npm i cardbuddy@latest` 不会更新工程里的 skills 副本——必须显式拉一次。本命令
7
+ * 就是为这个场景而生,且**只动 skills/**,绝不碰 .env.cb / package.json / README.md
8
+ * (那是 `init --force` 的危险面,可能冲掉端口 / FinMall 配置 / AI 网关占位)。
9
+ *
10
+ * 四类 diff(内容比对,非 mtime):
11
+ * ADDED 源有、目标无 → 复制(安全)
12
+ * SAME 两边内容一致 → 跳过
13
+ * MODIFIED 两边都有、内容不同 → 默认跳过 + 黄色警告(覆盖风险:消费者可能改过该 shipped skill)
14
+ * ORPHAN 目标有、源无 → 默认保留不删(可能是消费者私有 skill,或上游已下架该 skill)
15
+ *
16
+ * 标志:--force 覆盖 MODIFIED;--prune 额外删除 ORPHAN(危险,需显式);--dry-run 只报告;
17
+ * --strict 存在 MODIFIED 且未 --force 时返回非 0(便于 CI 感知未决覆盖)。
18
+ * 同步后写 skills/.synced-from = 当前装包版本,供下次 drift 检测与启动提示。
19
+ */
20
+ import fs from 'node:fs'
21
+ import path from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+ import chalk from 'chalk'
24
+ import { loadConfig } from './config.js'
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
27
+
28
+ const SKIP_DIRS = new Set(['node_modules', '__pycache__', '.git', '.local', '.cache', '.history'])
29
+ const SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db'])
30
+ const MARKER = '.synced-from'
31
+
32
+ function isSkipped(name, isDir) {
33
+ if (isDir) return SKIP_DIRS.has(name)
34
+ return SKIP_FILES.has(name) || name.endsWith('.pyc')
35
+ }
36
+
37
+ function fmtBytes(n) {
38
+ if (n < 1024) return `${n} B`
39
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
40
+ return `${(n / 1024 / 1024).toFixed(2)} MB`
41
+ }
42
+
43
+ /** 当前装包版本:发布态/源码态都读 <pkg>/package.json(dist 与 src 同深度)。 */
44
+ function ownVersion() {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8')).version || '*'
47
+ } catch {
48
+ return '*'
49
+ }
50
+ }
51
+
52
+ function contentEquals(a, b) {
53
+ try {
54
+ const sa = fs.statSync(a)
55
+ const sb = fs.statSync(b)
56
+ if (sa.size !== sb.size) return false
57
+ return fs.readFileSync(a).equals(fs.readFileSync(b))
58
+ } catch {
59
+ return false
60
+ }
61
+ }
62
+
63
+ /** 递归列出 dir 下所有文件(跳过私有/缓存),返回相对 base 的正斜杠路径。 */
64
+ function walkFiles(dir, base = dir, out = []) {
65
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
66
+ const fp = path.join(dir, entry.name)
67
+ if (isSkipped(entry.name, entry.isDirectory())) continue
68
+ if (entry.isDirectory()) walkFiles(fp, base, out)
69
+ else if (entry.isFile()) out.push(path.relative(base, fp).split(path.sep).join('/'))
70
+ }
71
+ return out
72
+ }
73
+
74
+ /**
75
+ * 比对源/目标技能树(内容比对)。
76
+ * @returns {{ added:string[], same:string[], modified:string[], orphan:string[] }}
77
+ */
78
+ export function diffSkills(src, dest) {
79
+ const res = { added: [], same: [], modified: [], orphan: [] }
80
+ if (fs.existsSync(src)) {
81
+ for (const rel of walkFiles(src)) {
82
+ const sp = path.join(src, rel)
83
+ const dp = path.join(dest, rel)
84
+ if (!fs.existsSync(dp)) res.added.push(rel)
85
+ else if (contentEquals(sp, dp)) res.same.push(rel)
86
+ else res.modified.push(rel)
87
+ }
88
+ }
89
+ if (fs.existsSync(dest)) {
90
+ for (const rel of walkFiles(dest)) {
91
+ if (rel === MARKER) continue
92
+ if (!fs.existsSync(path.join(src, rel))) res.orphan.push(rel)
93
+ }
94
+ }
95
+ return res
96
+ }
97
+
98
+ /**
99
+ * 执行 skills 升级。
100
+ * @param {object} opts
101
+ * @param {string} [opts.targetDir='.'] 目标工程根(skills 落在 <targetDir>/skills)
102
+ * @param {boolean} [opts.force] 覆盖本地已修改的 shipped skill
103
+ * @param {boolean} [opts.dryRun] 只报告不落盘
104
+ * @param {boolean} [opts.prune] 额外删除包内已不存在的技能文件(默认保留)
105
+ * @param {boolean} [opts.strict] 存在未覆盖 MODIFIED 时返回非 0
106
+ * @param {ReturnType<import('./config.js').loadConfig>} [opts.config]
107
+ * @param {(msg: string) => void} [opts.log]
108
+ * @returns {Promise<number>} 进程退出码
109
+ */
110
+ export async function runSkillsUpgrade({
111
+ targetDir = '.',
112
+ force = false,
113
+ dryRun = false,
114
+ prune = false,
115
+ strict = false,
116
+ config,
117
+ log = (m) => console.log(m),
118
+ } = {}) {
119
+ const cfg = config || loadConfig()
120
+ const src = cfg.vendoredSkillsDir
121
+ const target = path.resolve(targetDir)
122
+ const destSkills = path.join(target, 'skills')
123
+
124
+ const tag = chalk.magenta('▌ ') + chalk.magenta.bold('cardbuddy skills upgrade') + ' '
125
+ const say = (m) => log(tag + m)
126
+ const ok = (m) => say(chalk.green(`• ${m}`))
127
+ const warn = (m) => say(chalk.yellow(`• ${m}`))
128
+ const dim = (m) => say(chalk.dim(`• ${m}`))
129
+
130
+ if (dryRun) say(chalk.yellow('(dry-run:只报告将要做的事,不落盘)'))
131
+
132
+ if (!src || !fs.existsSync(src)) {
133
+ log(chalk.red(`✖ 找不到包内技能库快照:vendoredSkillsDir 缺失(cardbuddy 安装不完整?)`))
134
+ return 1
135
+ }
136
+ if (path.resolve(src) === path.resolve(destSkills)) {
137
+ dim(`技能库源与目标同路径(${src}),无需升级`)
138
+ return 0
139
+ }
140
+
141
+ const version = ownVersion()
142
+ const diff = diffSkills(src, destSkills)
143
+
144
+ let copied = 0
145
+ let bytes = 0
146
+ let overwritten = 0
147
+ let deleted = 0
148
+
149
+ // ADDED 总是复制;MODIFIED 仅 --force 时复制
150
+ const toCopy = [...diff.added, ...(force ? diff.modified : [])]
151
+ for (const rel of toCopy) {
152
+ const sp = path.join(src, rel)
153
+ const dp = path.join(destSkills, rel)
154
+ const isModified = diff.modified.includes(rel)
155
+ if (!dryRun) {
156
+ fs.mkdirSync(path.dirname(dp), { recursive: true })
157
+ fs.copyFileSync(sp, dp)
158
+ }
159
+ bytes += fs.statSync(sp).size
160
+ copied++
161
+ if (isModified) overwritten++
162
+ }
163
+
164
+ // MODIFIED 未 --force:警告(不复制,防丢改动)
165
+ if (!force && diff.modified.length) {
166
+ warn(`⚠ 检测到 ${diff.modified.length} 个已修改的技能文件(本地副本与包内不一致),默认不覆盖,避免丢失你的改动:`)
167
+ for (const rel of diff.modified) say(chalk.yellow(` ${rel}`))
168
+ dim(' 如需用包内版本覆盖,加 --force;想先看差异,加 --dry-run 后人工对比。')
169
+ } else if (force && diff.modified.length) {
170
+ warn(`--force:已用包内版本覆盖 ${diff.modified.length} 个本地修改的技能文件(你的改动将被丢弃)`)
171
+ }
172
+
173
+ // ORPHAN:默认保留;--prune 才删除
174
+ if (diff.orphan.length) {
175
+ if (prune) {
176
+ warn(`⚠ --prune:将删除 ${diff.orphan.length} 个包内已不存在的技能文件(本地私有或上游已下架):`)
177
+ for (const rel of diff.orphan) {
178
+ const dp = path.join(destSkills, rel)
179
+ say(chalk.yellow(` ${rel}`))
180
+ if (!dryRun) {
181
+ fs.rmSync(dp, { force: true })
182
+ deleted++
183
+ }
184
+ }
185
+ } else {
186
+ dim(`${diff.orphan.length} 个本地独有技能文件(包内无对应,可能是你私有的或上游已下架)—— 已保留不删`)
187
+ if (diff.orphan.length <= 20) for (const rel of diff.orphan) dim(` ${rel}`)
188
+ else dim(` (仅显示前 20,共 ${diff.orphan.length} 个;加 --prune 会删除它们,谨慎)`)
189
+ }
190
+ }
191
+
192
+ // 写同步标记(best-effort)
193
+ if (!dryRun) {
194
+ try {
195
+ fs.mkdirSync(destSkills, { recursive: true })
196
+ fs.writeFileSync(path.join(destSkills, MARKER), version + '\n', 'utf8')
197
+ } catch {
198
+ /* best-effort */
199
+ }
200
+ }
201
+
202
+ // 汇总
203
+ log('')
204
+ say(chalk.green(dryRun ? 'dry-run 完成(未落盘)' : 'skills 升级完成'))
205
+ say(chalk.dim(` 源:包内 cardbuddy@${version}`))
206
+ const targetNote = fs.existsSync(destSkills) ? destSkills : `${destSkills}(将新建)`
207
+ say(chalk.dim(` 目标:${targetNote}`))
208
+ say(
209
+ chalk.dim(
210
+ ` 新增 ${diff.added.length} · 相同 ${diff.same.length} · 覆盖 ${overwritten} · 保留私有 ${diff.orphan.length}` +
211
+ `${prune ? ` · 删除孤儿 ${deleted}` : ''} · 体积 ${fmtBytes(bytes)}`
212
+ )
213
+ )
214
+
215
+ if (strict && diff.modified.length && !force) {
216
+ say(chalk.red('(--strict:存在未覆盖的 MODIFIED 文件,返回非 0)'))
217
+ return 2
218
+ }
219
+ return 0
220
+ }
@@ -88,6 +88,9 @@ export function createCardRuntime(rootScopeRaw, adapter) {
88
88
  // pushApiCall 只在事件处理器中调用(postMessage / callJSAPI),不在
89
89
  // 渲染函数内,直接自增安全,无需 nextTick 延迟。
90
90
  const apiRev = reactive({ value: 0 })
91
+ // apiSeq:接口调用自增序号(模块级单调计数器)。作为每条调用的稳定 id,
92
+ // 既用于列表 key / 序号展示,也供单条删除按引用精确定位(避免下标在 FIFO 截断后错位)。
93
+ let apiSeq = 0
91
94
  // logRev:事件代码主动日志(console.*)流水版本号。与 apiRev 同理,仅在事件
92
95
  // 处理器(用户交互/生命周期动作)中被 pushLog 自增,不在渲染函数内,直接自增安全。
93
96
  const logRev = reactive({ value: 0 })
@@ -245,10 +248,19 @@ export function createCardRuntime(rootScopeRaw, adapter) {
245
248
  this.scheduleErrorBump()
246
249
  },
247
250
  pushApiCall(api, payload) {
248
- this.apiCalls.push({ api, payload, t: Date.now() })
251
+ this.apiCalls.push({ api, payload, t: Date.now(), id: ++apiSeq })
249
252
  if (this.apiCalls.length > 500) this.apiCalls.shift()
250
253
  apiRev.value++
251
254
  },
255
+ /** 单条删除某次接口调用:按 id 精确定位后 splice,并自增 apiRev 触发重渲染。
256
+ * 不依赖数组下标(FIFO 截断后下标会错位),可直接删除任一历史条目。 */
257
+ removeApiCall(call) {
258
+ if (!call || call.id == null) return
259
+ const i = this.apiCalls.findIndex((x) => x.id === call.id)
260
+ if (i < 0) return
261
+ this.apiCalls.splice(i, 1)
262
+ apiRev.value++
263
+ },
252
264
  addEventListener(name, fn) {
253
265
  if (!this.listeners[name]) this.listeners[name] = []
254
266
  this.listeners[name].push(fn)
@@ -2,9 +2,10 @@ import path from 'node:path'
2
2
  import { pathToFileURL } from 'node:url'
3
3
  import { createHash } from 'node:crypto'
4
4
  import { writeFile, mkdir } from 'node:fs/promises'
5
- import { checkPushAllowed } from './cardListCache.js'
5
+ import { checkPushAllowed, refreshCardList, hasCardId } from './cardListCache.js'
6
6
  import { assertRemoteSource } from './cardSidecar.js'
7
7
  import { log, fmtBytes } from './logger.js'
8
+ import { buildImportMeta, writeImportMeta } from './cardImportMeta.js'
8
9
  import { resolveSkillPathOrProject } from './skillPaths.js'
9
10
 
10
11
  /** finmall-card-sync 的批量同步脚本(同步 / 远端预览共用),相对 skills/ */
@@ -44,6 +45,13 @@ export function createSyncMiddleware({ root }) {
44
45
  const summary = await syncCards({
45
46
  cardCode: payload.cardCode || undefined,
46
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 },
47
55
  })
48
56
  // 同步成功后在 dev server 终端打印同步结果(统一美化框)。
49
57
  const lines = []
@@ -53,7 +53,13 @@ for (const key of ["NO_PROXY", "no_proxy"]) {
53
53
  // 仅在 5180 经中间件触发同步时本进程即 server 进程,缓存写入/读取天然一致;
54
54
  // 独立 CLI 运行时不写 server 缓存(无推送发生在该进程),故惰性加载、失败静默降级。
55
55
  let _cardListCache = null;
56
+ // 由 server/core 上下文注入(跨「仓库内 / 包内 vendored / 消费者工程 skills」三态都能正确定位
57
+ // cardListCache.js,刷新 dev server 进程内单例,供推送前「存在性闸门」使用)。缺省时退回按本文件
58
+ // 位置推导的路径动态 import——该 fallback 仅在脚本物理位于 <repo>/skills/ 时正确,消费者工程下
59
+ // workspace 会指向错误根,故优先用注入。
60
+ let _injectedCardListCache = null;
56
61
  async function getCardListCache() {
62
+ if (_injectedCardListCache) return _injectedCardListCache;
57
63
  if (_cardListCache) return _cardListCache;
58
64
  try {
59
65
  const cachePath = path.resolve(workspace, 'server', 'core', 'cardListCache.js');
@@ -129,7 +135,15 @@ async function finmallPostWithHeal(page, urlPath, requestBody, transCode = urlPa
129
135
  * @returns {Promise<{success:boolean, synced:Array, failed:Array, duplicates:Array}>}
130
136
  */
131
137
  export async function syncCards(opts = {}) {
132
- const outRoot = path.resolve(opts.outRoot || path.join(workspace, 'src', 'cards'));
138
+ // 非注入形态(纯 CLI)用 cwd 作为项目根,跨「仓库内 / 消费者工程」一致;server 中间件始终注入绝对 outRoot。
139
+ const outRoot = path.resolve(opts.outRoot || path.join(process.cwd(), 'src', 'cards'));
140
+ // 调用方可注入 cardImportMeta 实现(server/core 上下文,跨「仓库内 / 包内 vendored /
141
+ // 消费者工程 skills」三态都能正确定位 cardImportMeta.js)。缺省时退回按本文件位置推导的路径动态 import,
142
+ // 该 fallback 仅在脚本物理位于 <repo>/skills/ 时正确——消费者工程下 workspace 会指向错误根,故优先用注入。
143
+ const importMeta = opts.importMeta || null;
144
+ // 调用方可注入 cardListCache 实现(server/core 上下文,跨三态正确定位并刷新进程内单例)。
145
+ const cardListCacheMod = opts.cardListCache || null;
146
+ if (cardListCacheMod) _injectedCardListCache = cardListCacheMod;
133
147
  const startPage = Number(opts.startPage || 1);
134
148
  const startIndex = Number(opts.startIndex || 0);
135
149
  const pageSize = Number(opts.pageSize || 24);
@@ -771,6 +785,7 @@ async function syncCard(page, card, { outRoot, appId, workspaceId, summary, sche
771
785
  appId,
772
786
  workspaceId,
773
787
  card,
788
+ importMeta,
774
789
  files: { schema: schemaInfo.size, data: data.size, cover: cover.size },
775
790
  });
776
791
  return { cover: cover.size, schema: schemaInfo.size, data: data.size };
@@ -826,6 +841,7 @@ async function syncBlankCard(page, card, { cardDir, appId, workspaceId }) {
826
841
  appId,
827
842
  workspaceId,
828
843
  card,
844
+ importMeta,
829
845
  files: { schema: schemaInfo.size, data: dataInfo.size, cover: coverInfo ? coverInfo.size : 0 },
830
846
  });
831
847
 
@@ -936,11 +952,10 @@ async function findExistingCover(cardDir, id) {
936
952
  * 侧车独立于 schema.json/data.json,绝不污染 DSL 产品数据;找不到 server 模块时静默跳过。
937
953
  * 空白卡同样写侧车——pageId 已对齐远端 cardId,属于合法的远端来源卡,允许后续推送。
938
954
  */
939
- async function writeImportSidecar(cardDir, { appId, workspaceId, card, files }) {
955
+ async function writeImportSidecar(cardDir, { appId, workspaceId, card, files, importMeta }) {
940
956
  try {
941
- const metaPath = path.resolve(workspace, 'server', 'core', 'cardImportMeta.js');
942
- const { writeImportMeta, buildImportMeta } = await import(pathToFileURL(metaPath).href);
943
- const meta = buildImportMeta({
957
+ const mod = importMeta || (await import(pathToFileURL(path.resolve(workspace, 'server', 'core', 'cardImportMeta.js')).href));
958
+ const meta = mod.buildImportMeta({
944
959
  baseUrl: finmallConfig.baseUrl,
945
960
  appId,
946
961
  workspaceId,
@@ -949,7 +964,7 @@ async function writeImportSidecar(cardDir, { appId, workspaceId, card, files })
949
964
  cardName: card.title,
950
965
  files,
951
966
  });
952
- writeImportMeta(cardDir, meta);
967
+ mod.writeImportMeta(cardDir, meta);
953
968
  } catch (e) {
954
969
  log.warn('写导入元数据侧车失败(不影响卡片落盘)', { error: e && e.message });
955
970
  }
@@ -35,8 +35,9 @@ import { finmallConfig } from '../../shared/finmall-config.mjs';
35
35
 
36
36
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
37
37
  const skillDir = path.resolve(scriptDir, '..');
38
- // 与 api_batch_sync.mjs 一致:skill 位于 <repo-root>/skills/<name>,仓库根为上两级。
39
- const workspace = path.resolve(skillDir, '..', '..');
38
+ // 项目根:非注入形态(纯 CLI)用 cwd,跨「仓库内 / 消费者工程」一致;server 中间件始终注入绝对 root。
39
+ // 以往按 skills 位置向上跳两级的写法在「npx cardbuddy init」消费者工程下会指向错误根。
40
+ const workspace = process.cwd();
40
41
 
41
42
  // 统一日志美化(容错:缺 server/core/logger 时回退极简实现,保证脚本可独立运行)。
42
43
  let log;
@@ -433,8 +434,9 @@ function rewriteCardRefs(cardFiles, root, mapping) {
433
434
  * @returns {Promise<object>} 汇总结果
434
435
  */
435
436
  export async function uploadSlices(opts = {}) {
436
- const slicesRoot = path.resolve(opts.slicesRoot || path.join(workspace, 'src', 'slices'));
437
- const cardsRoot = path.resolve(opts.cardsRoot || path.join(workspace, 'src', 'cards'));
437
+ // 非注入形态(纯 CLI)用 cwd 作为项目根,跨「仓库内 / 消费者工程」一致;server 中间件始终注入绝对 slicesRoot/cardsRoot。
438
+ const slicesRoot = path.resolve(opts.slicesRoot || path.join(process.cwd(), 'src', 'slices'));
439
+ const cardsRoot = path.resolve(opts.cardsRoot || path.join(process.cwd(), 'src', 'cards'));
438
440
  const manifestPath = path.join(slicesRoot, MANIFEST_FILE);
439
441
  const appId = String(opts.appId || finmallConfig.appId);
440
442
  const workspaceId = String(opts.workspaceId || finmallConfig.workspaceId);
@@ -677,8 +679,8 @@ if (isMain) {
677
679
  noRewrite: !!args['no-rewrite'],
678
680
  appId: args.appId ? String(args.appId) : undefined,
679
681
  workspaceId: args.workspaceId ? String(args.workspaceId) : undefined,
680
- slicesRoot: args.slicesRoot ? path.resolve(workspace, String(args.slicesRoot)) : undefined,
681
- cardsRoot: args.cardsRoot ? path.resolve(workspace, String(args.cardsRoot)) : undefined,
682
+ slicesRoot: args.slicesRoot ? path.resolve(String(args.slicesRoot)) : undefined,
683
+ cardsRoot: args.cardsRoot ? path.resolve(String(args.cardsRoot)) : undefined,
682
684
  });
683
685
  const lines = [
684
686
  `目标:sassAppId=${summary.appId} sassWorkspaceId=${summary.workspaceId}${summary.dryRun ? '(dry-run,未上传)' : ''}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cardbuddy",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "Local dev service for the CardBuddy card editor — decouples card CRUD (sync/save/validate/render-status/screenshot) from the static public editor, so the editor can be deployed to any static host while each developer runs a local service against their own card source.",
5
5
  "type": "module",
6
6
  "bin": {