cardbuddy 0.19.2 → 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.
@@ -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-1exc5zRY.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-C_uqjKqI.css">
11
+ <script type="module" crossorigin src="/assets/index-Dx5hfJ7f.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-08GutEI0.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="app"></div>
@@ -7,18 +7,22 @@
7
7
  * 就是为这个场景而生,且**只动 skills/**,绝不碰 .env.cb / package.json / README.md
8
8
  * (那是 `init --force` 的危险面,可能冲掉端口 / FinMall 配置 / AI 网关占位)。
9
9
  *
10
- * 四类 diff(内容比对,非 mtime):
10
+ * 行为模型(upgrade = 显式要把新版带进来,「有差异 = 应当覆盖」,不做好坏判定):
11
11
  * ADDED 源有、目标无 → 复制(安全)
12
- * SAME 两边内容一致 → 跳过
13
- * MODIFIED 两边都有、内容不同 → 默认跳过 + 黄色警告(覆盖风险:消费者可能改过该 shipped skill)
14
- * ORPHAN 目标有、源无 → 默认保留不删(可能是消费者私有 skill,或上游已下架该 skill)
12
+ * SAME 两边一致 → 跳过
13
+ * MODIFIED 两边都有、内容不同 → 默认覆盖(这正是升级的目的,会丢弃本地副本的改动)
14
+ * ORPHAN 目标有、源无 → 默认保留(可能是私有 / 上游已下架)
15
15
  *
16
- * 标志:--force 覆盖 MODIFIED;--prune 额外删除 ORPHAN(危险,需显式);--dry-run 只报告;
17
- * --strict 存在 MODIFIED 且未 --force 时返回非 0(便于 CI 感知未决覆盖)。
16
+ * 确认门禁:当存在 MODIFIED(将覆盖并丢弃本地改动)时,默认进入交互确认——展示将覆盖的
17
+ * 文件清单,请用户 [y/N] 确认;确认即覆盖,否认即跳过这些 MODIFIED 并继续其余变更。
18
+ * 交互环境外(非 TTY)无法确认时,必须带 --force 才能升级,否则明确拒绝并提示(不再
19
+ * 静默跳过、假装升级成功)。
20
+ * 标志:--force/-y 跳过确认直接覆盖;--prune 额外删除 ORPHAN;--dry-run 只报告不落盘。
18
21
  * 同步后写 skills/.synced-from = 当前装包版本,供下次 drift 检测与启动提示。
19
22
  */
20
23
  import fs from 'node:fs'
21
24
  import path from 'node:path'
25
+ import readline from 'node:readline/promises'
22
26
  import { fileURLToPath } from 'node:url'
23
27
  import chalk from 'chalk'
24
28
  import { loadConfig } from './config.js'
@@ -95,14 +99,36 @@ export function diffSkills(src, dest) {
95
99
  return res
96
100
  }
97
101
 
102
+ /**
103
+ * 确认是否覆盖 MODIFIED 文件。
104
+ * - force/injected confirm:直接给结果(便于非交互 / 测试)
105
+ * - 交互 TTY:readline 逐条列出后请用户 [y/N]
106
+ * - 非 TTY 且无 --force:返回 null(调用方据此明确拒绝,而非静默跳过)
107
+ * @returns {Promise<boolean|null>}
108
+ */
109
+ async function resolveOverwriteConsent({ modified, force, confirm, log, tag }) {
110
+ if (force) return true
111
+ if (typeof confirm === 'function') return Boolean(await confirm(modified))
112
+ if (!process.stdin.isTTY) return null
113
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
114
+ try {
115
+ log(tag + chalk.yellow(`将覆盖 ${modified.length} 个本地已修改的技能文件(你的本地改动将被丢弃):`))
116
+ for (const rel of modified) log(chalk.yellow(' ' + rel))
117
+ const ans = await rl.question(chalk.bold('确认用包内版本覆盖以上文件?[y/N] '))
118
+ return /^(y|yes|是|确认|ok)$/i.test(ans.trim())
119
+ } finally {
120
+ rl.close()
121
+ }
122
+ }
123
+
98
124
  /**
99
125
  * 执行 skills 升级。
100
126
  * @param {object} opts
101
127
  * @param {string} [opts.targetDir='.'] 目标工程根(skills 落在 <targetDir>/skills)
102
- * @param {boolean} [opts.force] 覆盖本地已修改的 shipped skill
128
+ * @param {boolean} [opts.force] 跳过确认直接覆盖本地已修改的 shipped skill(非交互必备)
103
129
  * @param {boolean} [opts.dryRun] 只报告不落盘
104
130
  * @param {boolean} [opts.prune] 额外删除包内已不存在的技能文件(默认保留)
105
- * @param {boolean} [opts.strict] 存在未覆盖 MODIFIED 时返回非 0
131
+ * @param {(modified:string[])=>boolean|Promise<boolean>} [opts.confirm] 注入式确认(测试用,默认交互)
106
132
  * @param {ReturnType<import('./config.js').loadConfig>} [opts.config]
107
133
  * @param {(msg: string) => void} [opts.log]
108
134
  * @returns {Promise<number>} 进程退出码
@@ -112,7 +138,7 @@ export async function runSkillsUpgrade({
112
138
  force = false,
113
139
  dryRun = false,
114
140
  prune = false,
115
- strict = false,
141
+ confirm = null,
116
142
  config,
117
143
  log = (m) => console.log(m),
118
144
  } = {}) {
@@ -141,13 +167,36 @@ export async function runSkillsUpgrade({
141
167
  const version = ownVersion()
142
168
  const diff = diffSkills(src, destSkills)
143
169
 
170
+ if (!diff.added.length && !diff.modified.length && !diff.orphan.length && fs.existsSync(destSkills)) {
171
+ dim(`skills 已是最新(cardbuddy@${version},无新增 / 修改 / 孤儿)`)
172
+ return 0
173
+ }
174
+
175
+ // 确认门禁:存在将被覆盖的 MODIFIED 时,先确认
176
+ let overwriteModified = false
177
+ if (diff.modified.length && !dryRun) {
178
+ const consent = await resolveOverwriteConsent({ modified: diff.modified, force, confirm, log, tag })
179
+ if (consent === null) {
180
+ warn(`⚠ 检测到 ${diff.modified.length} 个已修改的技能文件将被覆盖,但当前非交互环境无法确认。`)
181
+ dim(' 加 --force 跳过确认直接覆盖,或在交互终端运行本命令确认。')
182
+ return 1
183
+ }
184
+ overwriteModified = consent
185
+ if (!overwriteModified) {
186
+ warn(`已跳过 ${diff.modified.length} 个 MODIFIED 文件(未确认覆盖,本地副本保留);其余变更仍会进行。`)
187
+ }
188
+ } else if (diff.modified.length && dryRun) {
189
+ warn(`dry-run:${diff.modified.length} 个已修改的技能文件将被覆盖(确认后)。`)
190
+ }
191
+
144
192
  let copied = 0
145
193
  let bytes = 0
146
194
  let overwritten = 0
195
+ let added = 0
147
196
  let deleted = 0
148
197
 
149
- // ADDED 总是复制;MODIFIED 仅 --force 时复制
150
- const toCopy = [...diff.added, ...(force ? diff.modified : [])]
198
+ // ADDED 总是复制;MODIFIED 仅当用户确认(或 --force)时复制——这正是升级的目的
199
+ const toCopy = [...diff.added, ...(overwriteModified ? diff.modified : [])]
151
200
  for (const rel of toCopy) {
152
201
  const sp = path.join(src, rel)
153
202
  const dp = path.join(destSkills, rel)
@@ -159,15 +208,7 @@ export async function runSkillsUpgrade({
159
208
  bytes += fs.statSync(sp).size
160
209
  copied++
161
210
  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} 个本地修改的技能文件(你的改动将被丢弃)`)
211
+ else added++
171
212
  }
172
213
 
173
214
  // ORPHAN:默认保留;--prune 才删除
@@ -207,14 +248,12 @@ export async function runSkillsUpgrade({
207
248
  say(chalk.dim(` 目标:${targetNote}`))
208
249
  say(
209
250
  chalk.dim(
210
- ` 新增 ${diff.added.length} · 相同 ${diff.same.length} · 覆盖 ${overwritten} · 保留私有 ${diff.orphan.length}` +
251
+ ` 新增 ${added} · 相同 ${diff.same.length} · 覆盖 ${overwritten}` +
252
+ `${overwriteModified ? '' : ` · 跳过修改 ${diff.modified.length - overwritten}`}` +
253
+ ` · 保留私有 ${diff.orphan.length}` +
211
254
  `${prune ? ` · 删除孤儿 ${deleted}` : ''} · 体积 ${fmtBytes(bytes)}`
212
255
  )
213
256
  )
214
257
 
215
- if (strict && diff.modified.length && !force) {
216
- say(chalk.red('(--strict:存在未覆盖的 MODIFIED 文件,返回非 0)'))
217
- return 2
218
- }
219
258
  return 0
220
259
  }
@@ -1,12 +1,13 @@
1
1
  <!--
2
2
  多列布局容器(官方组件 MultiColumn)。
3
3
 
4
- 预览运行时此前未注册该组件,导致画布显示「未知组件」占位(问题 #4)。
5
- 本组件对齐 Row/Column 的容器范式:用 StyleParser 解析样式(rect / flex / padding …),
6
- 渲染默认插槽内容;当 `column` 属性 > 1 时切换为 CSS Grid 多列布局(列数 = column)。
4
+ 官方 component-meta 里 MultiColumn 没有 column 属性(properties: []),
5
+ 真实运行时语义 = **直接子节点数即列数,单行均分**(N 个子节点 → N 等宽列)。
6
+ 预览器此前自造 `column=2` 默认值,导致 4 子节点被排成 2×2(overseaTransfer
7
+ 步骤条 row_hf 实测踩坑:内容高度翻倍后被 AbsoluteLayout 裁切,圆圈/标签错位)。
7
8
 
8
- 说明:当前测试卡的 MultiColumn 实例(comp_3)为「空容器」(无插槽子节点、无 column 属性),
9
- 此处仍提供完整的多列渲染能力,便于后续接入真实带子节点的多列布局卡片。
9
+ 实现:显式传 column > 0 时按指定列数(保留扩展口子);否则按默认插槽的
10
+ 子 vnode 数自动取列数,>1 时切换 CSS Grid 单行均分(flex gap 兼作列间距)。
10
11
  -->
11
12
  <template>
12
13
  <div ref="mc" class="ce_multicolumn" :style="styleObj" @click="$emit('click', $event)">
@@ -15,6 +16,7 @@
15
16
  </template>
16
17
 
17
18
  <script>
19
+ import { Comment, Text } from 'vue'
18
20
  import StyleParser from '../runtime/StyleParser.js'
19
21
  import base from './base.js'
20
22
 
@@ -24,16 +26,23 @@ export default {
24
26
  mixins: [base],
25
27
  props: {
26
28
  styleProps: { type: Object, default: () => ({}) },
27
- column: { type: [Number, String], default: 2 },
29
+ /** 列数。0 = 自动(默认):按子节点数单行均分,对齐官方「无 column 属性」语义 */
30
+ column: { type: [Number, String], default: 0 },
28
31
  },
29
32
  computed: {
33
+ cols() {
34
+ const explicit = parseInt(this.column, 10)
35
+ if (explicit > 0) return explicit
36
+ const vnodes = this.$slots.default ? this.$slots.default() : []
37
+ // 只统计真实子组件 vnode;注释 / 纯文本节点不计入
38
+ return vnodes.filter((v) => v && v.type !== Comment && v.type !== Text).length
39
+ },
30
40
  styleObj() {
31
41
  const s = new StyleParser(this, this.styleProps).parse()
32
- const cols = parseInt(this.column, 10)
33
- if (cols && cols > 1) {
34
- // 多列:用 grid 把子节点按 column 列均分;flex gap 同时充当 grid 行列间距。
42
+ if (this.cols > 1) {
43
+ // 多列:用 grid 把子节点单行均分;flex gap 同时充当 grid 列间距。
35
44
  s.display = 'grid'
36
- s.gridTemplateColumns = `repeat(${cols}, 1fr)`
45
+ s.gridTemplateColumns = `repeat(${this.cols}, 1fr)`
37
46
  }
38
47
  return s
39
48
  },
@@ -0,0 +1,203 @@
1
+ import { pathToFileURL } from 'node:url'
2
+ import { findCardDir } from './cardSidecar.js'
3
+ import { readImportMeta, markDirty } from './cardImportMeta.js'
4
+ import { writeCardFiles } from './cardFiles.js'
5
+ import { log } from './logger.js'
6
+ import { resolveSkillPathOrProject } from './skillPaths.js'
7
+
8
+ const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
9
+
10
+ /** 读取 JSON 请求体(1MB 上限防失控) */
11
+ function readJsonBody(req) {
12
+ return new Promise((resolve, reject) => {
13
+ let raw = ''
14
+ req.on('data', (chunk) => {
15
+ raw += chunk
16
+ if (raw.length > 1024 * 1024) {
17
+ reject(new Error('请求体过大'))
18
+ req.destroy()
19
+ }
20
+ })
21
+ req.on('end', () => {
22
+ try {
23
+ resolve(raw ? JSON.parse(raw) : {})
24
+ } catch {
25
+ reject(new Error('请求体不是合法 JSON'))
26
+ }
27
+ })
28
+ req.on('error', reject)
29
+ })
30
+ }
31
+
32
+ /**
33
+ * 调试面板「卡片历史记录」tab 的远端版本时间线(只读):GET /__card-history/:cardCode
34
+ *
35
+ * 语义:经 .import-meta.json 侧车里的 cardId 调 IDC10013,取该卡历史版本列表(新→旧),
36
+ * 每条含时间 / 预览图(/minio 同源路径)/ 版本记录 id / 相对上一版本的变更标签。
37
+ * 触发时机由前端控制(切到 tab / 点刷新 / 加载更多),不自动轮询——与「卡片来源」tab
38
+ * 的懒加载策略一致,避免每次加载卡片都拉起后台 Chrome。
39
+ *
40
+ * 恢复端点:POST /__card-history/:cardCode/restore(body { versionId })——把指定历史
41
+ * 版本的完整 schema 写入本地 src/cards/<code>/schema.json(原子写 + 派生 data.json),
42
+ * 并标记 dirty(本地偏离导入镜像;推送成功后自会清除)。watcher 推 SSE、编辑器重载后
43
+ * 即可继续编辑,经既有「推送」链路(IDC10010)保存为远端最新版本。
44
+ *
45
+ * 安全闸(与 /__card-version / /__save 同源):仅「已同步导入卡」(有 .import-meta.json
46
+ * 侧车且含 cardId)可操作;本地自建卡 / 未同步卡返回 409 local_card_no_source。
47
+ * 恢复另有防串卡校验:历史 schema.pageId 必须等于侧车 cardId,否则 409 pageId_mismatch。
48
+ *
49
+ * @param {{ root: string }} opts
50
+ * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
51
+ */
52
+ export function createCardHistoryMiddleware({ root }) {
53
+ return function cardHistoryMiddleware(req, res, next) {
54
+ const pathname = decodeURIComponent((req.url || '').split('?')[0])
55
+ const mRestore = pathname.match(/^\/__card-history\/([^/]+)\/restore$/)
56
+ const mList = pathname.match(/^\/__card-history\/([^/]+)$/)
57
+ if (!mRestore && !mList) return next()
58
+ const method = (req.method || 'GET').toUpperCase()
59
+ if (mRestore ? method !== 'POST' : method !== 'GET') return next()
60
+
61
+ const id = decodeURIComponent((mRestore || mList)[1])
62
+ const dir = findCardDir(root, { code: id })
63
+ const meta = dir ? readImportMeta(dir) : null
64
+ if (!meta || !meta.cardId) {
65
+ res.statusCode = 409
66
+ res.setHeader('Content-Type', 'application/json')
67
+ return res.end(
68
+ JSON.stringify({
69
+ ok: false,
70
+ error: 'local_card_no_source',
71
+ message: '该卡片为本地自建、无远端同步来源(缺少 .import-meta.json 侧车),无法查看远端历史。',
72
+ }),
73
+ )
74
+ }
75
+
76
+ if (mRestore) return handleRestore(req, res, { root, id, dir, meta })
77
+ return handleList(req, res, { root, id, meta })
78
+ }
79
+ }
80
+
81
+ /** GET 列表:分页参数(query)pageNo / pageSize,缺省 1 / 30,上限 100 防失控 */
82
+ function handleList(req, res, { root, id, meta }) {
83
+ const qs = new URL(req.url || '/', 'http://localhost').searchParams
84
+ const pageNo = Math.max(1, parseInt(qs.get('pageNo') || '1', 10) || 1)
85
+ const pageSize = Math.min(100, Math.max(1, parseInt(qs.get('pageSize') || '30', 10) || 30))
86
+
87
+ ;(async () => {
88
+ try {
89
+ const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
90
+ const { fetchCardHistory } = await import(pathToFileURL(skillPath).href)
91
+ const r = await fetchCardHistory(meta.cardId, { pageNo, pageSize })
92
+ log.ok('查询远端卡片历史', { cardCode: id, cardId: String(meta.cardId), count: r.list.length })
93
+ res.statusCode = 200
94
+ res.setHeader('Content-Type', 'application/json')
95
+ res.setHeader('Cache-Control', 'no-store')
96
+ res.end(
97
+ JSON.stringify({
98
+ ok: true,
99
+ cardId: String(meta.cardId),
100
+ cardCode: id,
101
+ list: r.list,
102
+ totalNums: r.totalNums,
103
+ totalPages: r.totalPages,
104
+ }),
105
+ )
106
+ } catch (e) {
107
+ // 区分「凭据缺失」与「真正的查询错误」:前者让前端提示登录,而非裸抛 ENOENT。
108
+ const isNoCred =
109
+ e && /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(String(e && e.message))
110
+ res.statusCode = isNoCred ? 200 : 500
111
+ res.setHeader('Content-Type', 'application/json')
112
+ res.end(
113
+ JSON.stringify(
114
+ isNoCred
115
+ ? { error: 'need_credentials', needCredentials: true, message: String(e && e.message) }
116
+ : { error: 'history failed', message: String(e && e.message) },
117
+ ),
118
+ )
119
+ }
120
+ })()
121
+ }
122
+
123
+ /** POST restore:按 versionId 取历史 schema → 防串卡校验 → 原子写 schema.json + 派生 data.json → 标记 dirty */
124
+ function handleRestore(req, res, { root, id, dir, meta }) {
125
+ ;(async () => {
126
+ try {
127
+ const body = await readJsonBody(req)
128
+ const versionId = String((body && body.versionId) || '')
129
+ if (!versionId) {
130
+ res.statusCode = 400
131
+ res.setHeader('Content-Type', 'application/json')
132
+ return res.end(JSON.stringify({ ok: false, error: 'missing_version_id', message: '缺少 versionId(远端版本记录 id)。' }))
133
+ }
134
+ const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
135
+ const { fetchCardVersionSchema } = await import(pathToFileURL(skillPath).href)
136
+ let schema
137
+ let record
138
+ try {
139
+ ;({ schema, record } = await fetchCardVersionSchema(meta.cardId, versionId))
140
+ } catch (e) {
141
+ // 缓存未命中(时间线未加载过 / TTL 过期 / 目标在更旧页):先拉一页 100 条预热
142
+ // (cardId, versionId) 缓存再重试——一页 4.2s,通常快于翻页到命中位置,且覆盖最新
143
+ // 100 个版本(用户点击的多为时间线可见项)。
144
+ if (!/找不到版本记录/.test(String(e && e.message))) throw e
145
+ const { fetchCardHistory } = await import(pathToFileURL(skillPath).href)
146
+ await fetchCardHistory(meta.cardId, { pageNo: 1, pageSize: 100 })
147
+ ;({ schema, record } = await fetchCardVersionSchema(meta.cardId, versionId))
148
+ }
149
+ // 防串卡:历史 schema 的 pageId 必须等于侧车登记的 cardId(与 /__save 的 pageId 闸门同语义)
150
+ if (String(schema.pageId) !== String(meta.cardId)) {
151
+ res.statusCode = 409
152
+ res.setHeader('Content-Type', 'application/json')
153
+ return res.end(
154
+ JSON.stringify({
155
+ ok: false,
156
+ error: 'pageId_mismatch',
157
+ message: `版本记录 #${versionId} 的 schema.pageId(${schema.pageId})与本地卡片来源(${meta.cardId})不一致,已拒绝加载。`,
158
+ }),
159
+ )
160
+ }
161
+ // 原子写 schema.json + 单向派生 data.json(与编辑器本地保存同一写入口)
162
+ const written = writeCardFiles(dir, { schema })
163
+ // 本地内容已偏离导入镜像:标记 dirty,来源 tab 可见;推送成功后由 recordExport/markDirty 清除
164
+ markDirty(dir, true)
165
+ log.ok('恢复远端历史版本到本地', {
166
+ cardCode: id,
167
+ cardId: String(meta.cardId),
168
+ versionId,
169
+ createTime: record.createTime,
170
+ written,
171
+ })
172
+ res.statusCode = 200
173
+ res.setHeader('Content-Type', 'application/json')
174
+ res.setHeader('Cache-Control', 'no-store')
175
+ res.end(
176
+ JSON.stringify({
177
+ ok: true,
178
+ cardCode: id,
179
+ cardId: String(meta.cardId),
180
+ versionId,
181
+ createTime: record.createTime,
182
+ written,
183
+ }),
184
+ )
185
+ } catch (e) {
186
+ const msg = String(e && e.message)
187
+ const isNoCred = /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(msg)
188
+ const isNotFound = /找不到版本记录|schema 为空或损坏/.test(msg)
189
+ const status = isNoCred ? 200 : isNotFound ? 404 : 500
190
+ res.statusCode = status
191
+ res.setHeader('Content-Type', 'application/json')
192
+ res.end(
193
+ JSON.stringify(
194
+ isNoCred
195
+ ? { ok: false, error: 'need_credentials', needCredentials: true, message: msg }
196
+ : isNotFound
197
+ ? { ok: false, error: 'version_not_found', message: msg }
198
+ : { ok: false, error: 'restore failed', message: msg },
199
+ ),
200
+ )
201
+ }
202
+ })()
203
+ }