cardbuddy 0.9.1 → 0.11.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.
Files changed (64) hide show
  1. package/README.md +21 -6
  2. package/dist/cli.js +17 -6
  3. package/dist/config.js +20 -2
  4. package/dist/portGuard.js +293 -0
  5. package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
  6. package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
  7. package/dist/preview-editor/index.html +2 -2
  8. package/dist/server-core/_vendor/finmall-env.mjs +4 -4
  9. package/dist/server-core/ai-chat.js +3710 -0
  10. package/dist/server-core/assetProxy.js +3 -22
  11. package/dist/server-core/buildDataFromSchema.js +36 -4
  12. package/dist/server-core/cardFiles.js +95 -2
  13. package/dist/server-core/cardImage.js +14 -0
  14. package/dist/server-core/cardListCache.js +0 -17
  15. package/dist/server-core/cardSave.js +2 -0
  16. package/dist/server-core/cardValidate.js +3 -0
  17. package/dist/server-core/index.js +6 -5
  18. package/dist/server-core/logStream.js +1 -6
  19. package/dist/server-core/sliceUpload.js +8 -1
  20. package/dist/standalone.js +24 -8
  21. package/dist/vendored/App.vue +405 -125
  22. package/dist/vendored/api.js +4 -4
  23. package/dist/vendored/assets/svgIcons/icons.svg +28 -0
  24. package/dist/vendored/components/AiSettings.vue +392 -0
  25. package/dist/vendored/components/CardGallery.vue +51 -5
  26. package/dist/vendored/components/ChatComposer.vue +413 -0
  27. package/dist/vendored/components/ChatMessage.vue +383 -0
  28. package/dist/vendored/components/ChatPanel.vue +937 -0
  29. package/dist/vendored/components/ComponentPalette.vue +1 -1
  30. package/dist/vendored/components/DebugPanel.vue +732 -90
  31. package/dist/vendored/components/Dropdown.vue +4 -2
  32. package/dist/vendored/components/MarkdownBody.vue +64 -0
  33. package/dist/vendored/components/NewCardDialog.vue +132 -0
  34. package/dist/vendored/components/NumberStepper.vue +125 -0
  35. package/dist/vendored/components/ProgressSlider.vue +9 -1
  36. package/dist/vendored/components/RemotePreview.vue +1 -1
  37. package/dist/vendored/components/StructureTree.vue +2 -2
  38. package/dist/vendored/main.js +1 -0
  39. package/dist/vendored/runtime/adapter.js +1 -1
  40. package/dist/vendored/runtime/ai-api.js +165 -0
  41. package/dist/vendored/runtime/clipboard-node.js +0 -5
  42. package/dist/vendored/runtime/markdown.js +140 -0
  43. package/dist/vendored/skills/.manifest.json +1101 -0
  44. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
  46. package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
  49. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
  50. package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
  51. package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
  52. package/dist/vendored/styles/ai-chat.css +1850 -0
  53. package/dist/vendored/styles/app.css +725 -213
  54. package/dist/vendored/styles/card-gallery.css +48 -10
  55. package/dist/vendored/styles/card-select.css +13 -6
  56. package/dist/vendored/styles/dropdown.css +1 -2
  57. package/dist/vendored/styles/json-tree.css +3 -3
  58. package/dist/vendored/styles/node-inspector.css +13 -289
  59. package/dist/vendored/styles/setters.css +48 -49
  60. package/dist/vendored/styles/structure-tree.css +6 -11
  61. package/package.json +1 -1
  62. package/dist/preview-editor/assets/index-B9MOriNv.js +0 -80
  63. package/dist/preview-editor/assets/index-CwD14gm3.css +0 -1
  64. package/dist/vendored/assets/logo.png +0 -0
package/README.md CHANGED
@@ -66,6 +66,7 @@ cardbuddy --preview # 等价短参 -p
66
66
  | --- | --- | --- |
67
67
  | `DSL_CARD_ROOT` | 当前目录 (`cwd`) | 卡片代码仓库根(含 `src/cards`)。可用环境变量二次覆盖。 |
68
68
  | `DSL_DEV_PORT` | `8787` | 本地服务监听端口。 |
69
+ | `DSL_DEV_PORT_TAKEOVER` | `auto` | 端口被占用时的接管策略:`auto` 自动结束占用该端口的旧 cardbuddy 实例后启动;`force` 连无关进程一并结束;`off` 关闭前置检查(退回原生 `EADDRINUSE` 报错)。 |
69
70
  | `EDITOR_URL` | `http://127.0.0.1:5180` | 截图自访问的编辑器实例地址(默认指向本地编辑器)。写入 `.env` 即可持久化。 |
70
71
  | `DSL_DEV_TOKEN` | _(空)_ | 可选鉴权令牌;开启后请求须带 `x-dev-token` 头。默认仅靠 `127.0.0.1` 绑定(不暴露局域网)。 |
71
72
  | `DSL_DEV_HTTPS` | `0` | 设为 `1` 启用自签 HTTPS(兜底旧 Safari / 企业策略的混合内容拦截)。 |
@@ -76,6 +77,20 @@ cardbuddy --preview # 等价短参 -p
76
77
 
77
78
  ---
78
79
 
80
+ ## 端口被占用时自动接管
81
+
82
+ 启动前先探测 `DSL_DEV_PORT`:若被**上一个 cardbuddy 实例**占用(终端直接关掉、`Ctrl+C` 漏杀、后台残留等),
83
+ 自动 `SIGTERM`(宽限 2.5s 后升级 `SIGKILL`)结束旧进程、等端口真正释放,再继续启动,免去手动
84
+ `lsof -ti tcp:8787 | xargs kill`。
85
+
86
+ 判定「占用者是本服务」有两条依据:HTTP 探测 `/__cards` 返回 cardbuddy 响应体(`{ cards: [...] }` 或 token 鉴权 403),
87
+ 或占用进程命令行含 `cardbuddy` / `dsl-card` 字样。**无关进程占着端口时不会动手**,而是直接报错并给出手动处理命令;
88
+ 确需强制接管设 `DSL_DEV_PORT_TAKEOVER=force`。
89
+
90
+ 跨平台:macOS / Linux 用 `lsof` 定位 PID(缺失时回退 `ss`),Windows 用 `netstat -ano` + `taskkill /T /F`。
91
+
92
+ ---
93
+
79
94
  ## HTTPS 模式(混合内容兜底)
80
95
 
81
96
  默认本服务跑在 `http://127.0.0.1:8787`。当公网编辑器是 **HTTPS** 时,浏览器可能因「混合内容(Mixed Content)」或「私有网络访问(PNA)」策略拦截对 `http://127.0.0.1` 的跨源请求。现代 **Chrome / Firefox** 对回环地址(127.0.0.1 / localhost)豁免混合内容,默认即可用;但 **Safari** 等更严格的环境会拦截,此时启用 HTTPS 模式:
@@ -116,7 +131,7 @@ DSL_DEV_HTTPS=1 cardbuddy
116
131
  | `POST /__code-bridge/:id/heartbeat` | 自包含 | 代码桥:会话心跳(前端 5s 一次) |
117
132
  | `DELETE /__code-bridge/:id` | 自包含 | 代码桥:结束会话并删除临时文件 |
118
133
 
119
- > **FinMall 凭据只在本机**:`/__sync` `/__save` `/__preview` 由本地服务代理 FinMall,凭据存于本机 `skills/finmall-card-sync` 与 `.env.finmall`,公网前端永不接触。
134
+ > **FinMall 凭据只在本机**:`/__sync` `/__save` `/__preview` 由本地服务代理 FinMall,凭据存于本机 `skills/finmall-card-sync` 与 `.env.cb`,公网前端永不接触。
120
135
 
121
136
  ### VSCode 代码桥(事件面板代码编辑区)
122
137
 
@@ -143,7 +158,7 @@ DSL_DEV_HTTPS=1 cardbuddy
143
158
  - `finmall-card-sync`(sync / save / preview)
144
159
  - `dsl-card-generator`(validate)
145
160
 
146
- 请确保项目根下存在 `skills/finmall-card-sync/scripts/api_batch_sync.mjs` 等,且 `.env.finmall` 已配置 `FINMALL_BASE_URL` 等。
161
+ 请确保项目根下存在 `skills/finmall-card-sync/scripts/api_batch_sync.mjs` 等,且 `.env.cb` 已配置 `FINMALL_BASE_URL` 等。
147
162
 
148
163
  ---
149
164
 
@@ -168,7 +183,7 @@ DSL_DEV_HTTPS=1 cardbuddy
168
183
 
169
184
  | 命令 | 编辑器来源 | 后端来源 | 用途 |
170
185
  | --- | --- | --- | --- |
171
- | `npm run dev` | `preview/` 源码(Vite dev + HMR) | Vite 插件同源挂载(5180) | 日常改编辑器 / 卡片(同域,**不**走生产跨域链路) |
186
+ | `npm run dev` | `preview/` 源码(Vite dev + HMR,5180) | `cardbuddy` 服务(8787,由 `tools/dev.mjs` 同起) | 日常改编辑器 / 卡片 |
172
187
  | `npm run dev:prod` | `dist/` **构建产物**(`vite preview`) | `cardbuddy` 服务(8787) | 部署前权威跨域 smoke test(最贴近生产) |
173
188
  | 部署公网 | `dist/` 静态托管(CDN) | 用户本机起 8787 | 生产环境 |
174
189
 
@@ -178,11 +193,11 @@ DSL_DEV_HTTPS=1 cardbuddy
178
193
 
179
194
  ```bash
180
195
  npm run dev
181
- # → Vite dev server 端口 5180(由 .env.finmall 的 FINMALL_DEV_PORT 控制)
196
+ # → Vite dev server 端口 5180(由 .env.cb 的 FINMALL_DEV_PORT 控制)
182
197
  # 浏览器打开 http://localhost:5180
183
198
  ```
184
199
 
185
- `/__cards` `/__card/:id` `/__render-status` `/__node-models` 等后端端点由 Vite 插件 `server/cardServer.js` **同源挂载**,编辑器前端直接同源调用,无需单独起服务。
200
+ `/__cards` `/__card/:id` `/__render-status` `/__node-models` 等后端端点全部由 `cardbuddy`(8787)提供:`server/core` 的中间件经 `mountCoreMiddlewares` 挂到它的 connect 实例,Vite 不承载任何后端插件。编辑器前端在 DEV 下经 `resolveApiBase()` 默认直连 `http://127.0.0.1:8787`(用非回环地址打开时走 `vite.config.mjs` 的 `cardDevProxy` 同源兜底,规避浏览器私有网络访问限制)。
186
201
 
187
202
  ### 模式二:开发期顺手看跨域(可选)
188
203
 
@@ -219,7 +234,7 @@ npm run dev:prod
219
234
 
220
235
  ### 前置条件
221
236
 
222
- - `.env.finmall` 已存在(含 `FINMALL_BASE_URL` 远端基址、`FINMALL_DEV_PORT`)。缺它编辑器仍能起,只是远端资源代理会告警。
237
+ - `.env.cb` 已存在(含 `FINMALL_BASE_URL` 远端基址、`FINMALL_DEV_PORT`)。缺它编辑器仍能起,只是远端资源代理会告警。
223
238
  - 首次跑前先 `npm install`(仓库根)。
224
239
 
225
240
  ### 服务侧单独起(调试 8787 本身)
package/dist/cli.js CHANGED
@@ -2,13 +2,13 @@
2
2
  /**
3
3
  * cardbuddy CLI 入口(npm 包名与可执行命令同为 cardbuddy;0.6.0 前叫 cardbuddy-dev)。
4
4
  *
5
- * 读取 cwd 下 .env / .env.finmall / .env.finmall.local(dotenv),启动本地开发服务(127.0.0.1)。
5
+ * 读取 cwd 下 .env / .env.cb / .env.cb.local(dotenv),启动本地开发服务(127.0.0.1)。
6
6
  * 配置项见 src/config.js(DSL_CARD_ROOT / FINMALL_CARD_PORT / DSL_DEV_PORT /
7
7
  * DSL_DEV_TOKEN / DSL_DEV_HTTPS / EDITOR_URL / DSL_EDITOR_SRC)。
8
8
  *
9
- * 端口约定:.env.finmall 的 FINMALL_CARD_PORT 为「卡片开发服务端口」单一真值,
9
+ * 端口约定:.env.cb 的 FINMALL_CARD_PORT 为「卡片开发服务端口」单一真值,
10
10
  * 主仓库由 tools/dev.mjs 把它注入为 DSL_DEV_PORT;此处直接用 FINMALL_CARD_PORT,
11
- * 使 demo(直接 npx cardbuddy 或 npm run dev,无启动器注入)也能经 .env.finmall 生效。
11
+ * 使 demo(直接 npx cardbuddy 或 npm run dev,无启动器注入)也能经 .env.cb 生效。
12
12
  */
13
13
  import dotenv from 'dotenv'
14
14
  import chalk from 'chalk'
@@ -25,10 +25,17 @@ const srcTag =
25
25
  ? ''
26
26
  : chalk.magenta('▌ ') + chalk.magenta.bold('cardbuddy') + ' '
27
27
 
28
- // 加载环境(与 skills 同套约定:.env.finmall 为共享配置,.env.finmall.local 为个人覆盖层)。
28
+ // 加载环境(与 skills 同套约定:.env.cb 为共享配置,.env.cb.local 为个人覆盖层)。
29
29
  // dotenv 默认不覆盖已存在变量,故主仓库由启动器注入的 DSL_DEV_PORT / FINMALL_BASE_URL 仍优先。
30
- for (const p of ['.env', '.env.finmall', '.env.finmall.local']) {
31
- dotenv.config({ path: p })
30
+ // 同时登记「dotenv 注入值」:server/core/ai-chat.js 据此区分「启动时灌进 process.env 的文件值」
31
+ // 与「真实环境变量覆盖」,否则磁盘上改了 .env.cb.local 也会被启动快照遮挡。
32
+ for (const p of ['.env', '.env.cb', '.env.cb.local']) {
33
+ const loaded = dotenv.config({ path: p })
34
+ for (const [k, v] of Object.entries(loaded.parsed || {})) {
35
+ if (process.env[k] === v) {
36
+ globalThis.__cardbuddyDotenvInjected = Object.assign(globalThis.__cardbuddyDotenvInjected || {}, { [k]: v })
37
+ }
38
+ }
32
39
  }
33
40
 
34
41
  const args = process.argv.slice(2)
@@ -42,6 +49,7 @@ if (args.includes('--help') || args.includes('-h')) {
42
49
  --preview, -p 同时托管打包后的编辑器前端(本地预览),替代线上地址,并自动打开浏览器
43
50
  旧名 --offline / -o 仍可用,已废弃
44
51
  --help, -h 显示帮助
52
+ init 初始化项目骨架:创建 src/cards/ 目录,释放内置技能脚本到 skills/,生成模板 .env.cb
45
53
 
46
54
  环境变量:
47
55
  DSL_CARD_ROOT 卡片仓库根目录(默认 cwd)
@@ -50,6 +58,9 @@ if (args.includes('--help') || args.includes('-h')) {
50
58
  DSL_DEV_TOKEN 可选鉴权 token
51
59
  DSL_DEV_PREVIEW=1 等价于 --preview
52
60
  DSL_PREVIEW_ROOT 自定义 preview 前端构建产物目录(默认包内 dist/preview-editor,回退 cwd/dist)
61
+ DSL_DEV_PORT_TAKEOVER=auto|force|off
62
+ 端口被占用时的接管策略。auto(默认)自动结束占用端口的旧 cardbuddy 实例后启动;
63
+ force 连无关进程一并结束;off 关闭前置检查(退回原生 EADDRINUSE 报错)
53
64
 
54
65
  已废弃别名(仍生效):--offline / -o、DSL_DEV_OFFLINE、DSL_OFFLINE_ROOT
55
66
  `)
package/dist/config.js CHANGED
@@ -3,13 +3,14 @@
3
3
  *
4
4
  * 约定:
5
5
  * - 项目根默认 process.cwd(),可用 DSL_CARD_ROOT 覆盖。
6
- * - 监听端口默认 8787,由 FINMALL_CARD_PORT 控制(.env.finmall 单一真值);
6
+ * - 监听端口默认 8787,由 FINMALL_CARD_PORT 控制(.env.cb 单一真值);
7
7
  * 兼容旧 DSL_DEV_PORT(主仓库 tools/dev.mjs / prod-preview.mjs 注入)。
8
8
  * 前端 resolveApiBase 的 DEV 默认端口同样读 FINMALL_CARD_PORT,保持三者一致。
9
9
  * - 编辑器 URL 默认 http://127.0.0.1:5180(EDITOR_URL),供截图使用。
10
10
  * - 仅绑定 127.0.0.1;token / https 为可选增强。
11
11
  */
12
12
  import path from 'node:path'
13
+ import fs from 'node:fs'
13
14
  import { fileURLToPath } from 'node:url'
14
15
 
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -29,11 +30,21 @@ export function loadConfig(env = process.env) {
29
30
  const truthy = (value) => value === '1' || value === 'true'
30
31
  const preview = truthy(env.DSL_DEV_PREVIEW) || truthy(env.DSL_DEV_OFFLINE)
31
32
  const previewRoot = env.DSL_PREVIEW_ROOT || env.DSL_OFFLINE_ROOT || ''
33
+ // 端口接管策略:启动时若目标端口已被「上一个 cardbuddy 实例」占用,主动结束旧进程再接管,
34
+ // 免去手动 lsof + kill(详见 src/portGuard.js)。
35
+ // auto(默认)只结束能确认是本服务的进程;force 连无关进程一并结束;off 关闭前置检查,
36
+ // 退回原生 EADDRINUSE 行为。
37
+ const takeover = String(env.DSL_DEV_PORT_TAKEOVER || 'auto').toLowerCase()
38
+ const portTakeover = ['auto', 'force', 'off'].includes(takeover) ? takeover : 'auto'
32
39
  // FinMall 远端站点基址:资源代理(createAssetProxyMiddleware)回源用。
33
- // 由 .env.finmall 的 FINMALL_BASE_URL 经启动器(tools/dev.mjs / prod-preview.mjs)
40
+ // 由 .env.cb 的 FINMALL_BASE_URL 经启动器(tools/dev.mjs / prod-preview.mjs)
34
41
  // 注入到本进程 env;缺省空串表示不回源(/minio 等只走占位图兜底)。
35
42
  const upstream = env.FINMALL_BASE_URL || ''
36
43
 
44
+
45
+ const repoSkillsDir = path.resolve(process.cwd(), 'skills')
46
+ const vendoredSkillsDir = path.resolve(__dirname, 'vendored', 'skills')
47
+
37
48
  return {
38
49
  root,
39
50
  port,
@@ -44,6 +55,7 @@ export function loadConfig(env = process.env) {
44
55
  preview,
45
56
  previewRoot,
46
57
  upstream,
58
+ portTakeover,
47
59
  // 包内 vendored 运行时快照(build 阶段从 preview/ 复制到 dist/vendored):SSR 端点
48
60
  // 所需的编辑器运行时(components/* + runtime/*)全量快照,使发布后无需消费者持有编辑器源码。
49
61
  // - 源码运行(src/cli.js,__dirname=.../src):此处指向 .../src/vendored(不存在)→
@@ -53,11 +65,17 @@ export function loadConfig(env = process.env) {
53
65
  vendoredRuntimeDir: path.resolve(__dirname, 'vendored'),
54
66
  // 仓库内开发回退:从仓库根运行时(cwd=仓库根)能直接命中 preview/,无需先 build。
55
67
  repoPreviewDir: path.resolve(process.cwd(), 'preview'),
68
+ // 包内 vendored 技能脚本(build 阶段从 skills/ 复制到 dist/vendored/skills):运行时回退
69
+ vendoredSkillsDir,
70
+ // 仓库内 skills/ 目录(开发期直接命中)
71
+ repoSkillsDir,
56
72
  // 发布态包内自带的预览编辑器构建产物(build.mjs 从 ../../dist 复制到 dist/preview-editor)。
57
73
  previewEditorDir: path.resolve(__dirname, 'preview-editor'),
58
74
  // 兼容旧包布局(<= 0.5.x 的 dist/offline-editor):存在时作为回退,避免升级后 --preview 找不到前端。
59
75
  legacyOfflineEditorDir: path.resolve(__dirname, 'offline-editor'),
60
76
  // 仓库内开发回退:根目录 npm run build 的产物 dist/。
61
77
  repoDistDir: path.resolve(process.cwd(), 'dist'),
78
+ // 技能脚本根目录:优先用户项目 skills/,否则回退包内 vendored
79
+ skillsRoot: fs.existsSync(repoSkillsDir) ? repoSkillsDir : (fs.existsSync(vendoredSkillsDir) ? vendoredSkillsDir : null),
62
80
  }
63
81
  }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * 端口占用守卫 —— cardbuddy 启动前置检查。
3
+ *
4
+ * 背景:编辑器 / dev 启动器重启时,上一个 cardbuddy 进程偶尔不会被回收干净(终端直接关掉、
5
+ * Ctrl+C 漏杀、后台残留等),新实例便以 EADDRINUSE 直接启动失败,开发者只能手动
6
+ * `lsof -ti tcp:8787 | xargs kill`。这里把这套动作内置进启动流程:
7
+ * 探测端口 → 定位占用进程 → 确认是本服务 → SIGTERM(宽限期后 SIGKILL)→ 等端口真正释放。
8
+ *
9
+ * 安全边界:默认(mode='auto')只结束「能确认是 cardbuddy」的进程 —— HTTP 探测 /__cards 命中
10
+ * 本服务响应,或命令行含 cardbuddy / dsl-card 字样;占用者是无关进程时不动手,直接报错并给出
11
+ * 手动处理命令。确需强制接管用 DSL_DEV_PORT_TAKEOVER=force,完全关闭前置检查用 off。
12
+ *
13
+ * 跨平台:macOS / Linux 走 lsof(缺失时回退 ss),Windows 走 netstat -ano + taskkill。
14
+ */
15
+ import net from 'node:net'
16
+ import http from 'node:http'
17
+ import https from 'node:https'
18
+ import { execFile } from 'node:child_process'
19
+ import { promisify } from 'node:util'
20
+ import chalk from 'chalk'
21
+
22
+ const execFileAsync = promisify(execFile)
23
+
24
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
25
+
26
+ /** 命令行特征:命中即认为是本服务(覆盖 npx cardbuddy、node .../packages/cardbuddy/src/cli.js 等启动形态)。 */
27
+ const OWN_COMMAND_RE = /cardbuddy|dsl-card/i
28
+
29
+ const POLL_INTERVAL_MS = 120
30
+ const TERM_GRACE_MS = 2500
31
+ const KILL_GRACE_MS = 2000
32
+ const PROBE_TIMEOUT_MS = 900
33
+
34
+ /** 执行外部命令;失败(含 lsof 无匹配时的退出码 1)一律退化为空输出,由调用方按「没找到」处理。 */
35
+ async function tryExec(cmd, args) {
36
+ try {
37
+ const { stdout } = await execFileAsync(cmd, args, { windowsHide: true, timeout: 5000 })
38
+ return String(stdout || '')
39
+ } catch (err) {
40
+ return String(err?.stdout || '')
41
+ }
42
+ }
43
+
44
+ /** 试绑端口判断是否空闲:比 TCP connect 更贴近「我到底能不能 listen」这个真实问题。 */
45
+ export function probePort(port, host = '127.0.0.1') {
46
+ return new Promise((resolve) => {
47
+ const tester = net.createServer()
48
+ tester.once('error', (err) => resolve({ free: false, code: err?.code || 'ERROR' }))
49
+ tester.once('listening', () => tester.close(() => resolve({ free: true, code: null })))
50
+ tester.listen(port, host)
51
+ })
52
+ }
53
+
54
+ function manualHint(port) {
55
+ return process.platform === 'win32'
56
+ ? `netstat -ano | findstr :${port} → taskkill /PID <pid> /F`
57
+ : `lsof -ti tcp:${port} | xargs kill -9`
58
+ }
59
+
60
+ /** Windows:解析 `netstat -ano -p TCP` 的 LISTENING 行。 */
61
+ export function parseWindowsNetstat(stdout, port) {
62
+ const pids = new Set()
63
+ for (const line of String(stdout).split(/\r?\n/)) {
64
+ const cols = line.trim().split(/\s+/)
65
+ if (cols.length < 5 || cols[0].toUpperCase() !== 'TCP') continue
66
+ if (!cols[1].endsWith(`:${port}`) || !/LISTENING/i.test(cols[3])) continue
67
+ const pid = Number(cols[4])
68
+ if (Number.isInteger(pid) && pid > 0) pids.add(pid)
69
+ }
70
+ return [...pids]
71
+ }
72
+
73
+ /** Linux 回退(无 lsof 时):解析 `ss -ltnp` 的 users:(("node",pid=1234,fd=23))。 */
74
+ export function parseSs(stdout, port) {
75
+ const pids = new Set()
76
+ for (const line of String(stdout).split(/\r?\n/)) {
77
+ const cols = line.trim().split(/\s+/)
78
+ if (cols.length < 4 || !/LISTEN/i.test(cols[0])) continue
79
+ if (!cols[3].endsWith(`:${port}`)) continue
80
+ for (const m of line.matchAll(/pid=(\d+)/g)) pids.add(Number(m[1]))
81
+ }
82
+ return [...pids]
83
+ }
84
+
85
+ /** 定位监听该端口的进程 pid(已排除本进程)。 */
86
+ export async function findListenerPids(port) {
87
+ let pids = []
88
+ if (process.platform === 'win32') {
89
+ pids = parseWindowsNetstat(await tryExec('netstat', ['-ano', '-p', 'TCP']), port)
90
+ } else {
91
+ pids = String(await tryExec('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']))
92
+ .split(/\s+/)
93
+ .map(Number)
94
+ .filter((n) => Number.isInteger(n) && n > 0)
95
+ if (!pids.length) pids = parseSs(await tryExec('ss', ['-ltnp']), port)
96
+ }
97
+ return [...new Set(pids)].filter((pid) => pid !== process.pid)
98
+ }
99
+
100
+ /** 取进程命令行:既用于日志,也是「是不是本服务」的兜底判据。 */
101
+ export async function readProcessCommand(pid) {
102
+ if (process.platform === 'win32') {
103
+ const out = await tryExec('powershell', [
104
+ '-NoProfile',
105
+ '-Command',
106
+ `(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`,
107
+ ])
108
+ const line = out.trim().split(/\r?\n/).filter(Boolean).pop()
109
+ if (line) return line
110
+ const csv = await tryExec('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'])
111
+ return csv.split('","')[0].replace(/"/g, '').trim()
112
+ }
113
+ const out = await tryExec('ps', ['-p', String(pid), '-o', 'command='])
114
+ return out.trim().split(/\r?\n/)[0] || ''
115
+ }
116
+
117
+ export async function findPortOwners(port) {
118
+ const owners = []
119
+ for (const pid of await findListenerPids(port)) {
120
+ owners.push({ pid, command: await readProcessCommand(pid) })
121
+ }
122
+ return owners
123
+ }
124
+
125
+ /** 判定响应是否来自 cardbuddy:/__cards 的 { cards: [...] },或 token 鉴权失败的 403。 */
126
+ export function classifyProbe(status, body) {
127
+ const text = String(body || '')
128
+ if (status === 403 && /x-dev-token/i.test(text)) return { ours: true, detail: 'token 鉴权 403' }
129
+ if (status !== 200) return { ours: false, detail: `HTTP ${status}` }
130
+ try {
131
+ const json = JSON.parse(text)
132
+ if (json && Array.isArray(json.cards)) return { ours: true, detail: `${json.cards.length} 张卡片` }
133
+ } catch {
134
+ /* 非 JSON:不是本服务 */
135
+ }
136
+ return { ours: false, detail: 'HTTP 200(非 cardbuddy 响应)' }
137
+ }
138
+
139
+ function probeOnce(client, port, token, timeout) {
140
+ return new Promise((resolve) => {
141
+ const req = client.get(
142
+ {
143
+ host: '127.0.0.1',
144
+ port,
145
+ path: '/__cards',
146
+ timeout,
147
+ rejectUnauthorized: false,
148
+ headers: token ? { 'x-dev-token': token } : {},
149
+ },
150
+ (res) => {
151
+ let body = ''
152
+ res.setEncoding('utf8')
153
+ res.on('data', (chunk) => {
154
+ body += chunk
155
+ if (body.length > 256 * 1024) req.destroy()
156
+ })
157
+ res.on('end', () => resolve(classifyProbe(res.statusCode, body)))
158
+ res.on('error', () => resolve(null))
159
+ }
160
+ )
161
+ req.on('timeout', () => {
162
+ req.destroy()
163
+ resolve(null)
164
+ })
165
+ req.on('error', () => resolve(null))
166
+ })
167
+ }
168
+
169
+ /** 探测端口上的服务是不是 cardbuddy:先按本次启动的协议试,再试另一种(旧实例可能协议不同)。 */
170
+ export async function probeService(port, { token = '', tls = false, timeout = PROBE_TIMEOUT_MS } = {}) {
171
+ let foreign = null
172
+ for (const client of tls ? [https, http] : [http, https]) {
173
+ const hit = await probeOnce(client, port, token, timeout)
174
+ if (hit?.ours) return hit
175
+ if (hit && !foreign) foreign = hit
176
+ }
177
+ return foreign || { ours: false, detail: '无有效响应' }
178
+ }
179
+
180
+ function isAlive(pid) {
181
+ try {
182
+ process.kill(pid, 0)
183
+ return true
184
+ } catch (err) {
185
+ return err?.code === 'EPERM'
186
+ }
187
+ }
188
+
189
+ /** 结束进程:POSIX 走信号(SIGTERM 给旧实例一次优雅关闭的机会),Windows 走 taskkill /T /F。 */
190
+ async function terminate(pid, sig) {
191
+ if (process.platform === 'win32') {
192
+ await tryExec('taskkill', ['/PID', String(pid), '/T', '/F'])
193
+ return
194
+ }
195
+ try {
196
+ process.kill(pid, sig)
197
+ } catch {
198
+ /* ESRCH:已经退出 */
199
+ }
200
+ }
201
+
202
+ async function waitForPortFree(port, host, budgetMs) {
203
+ const deadline = Date.now() + budgetMs
204
+ for (;;) {
205
+ const { free } = await probePort(port, host)
206
+ if (free) return true
207
+ if (Date.now() >= deadline) return false
208
+ await sleep(POLL_INTERVAL_MS)
209
+ }
210
+ }
211
+
212
+ async function terminateOwner(owner, { port, host, log }) {
213
+ await terminate(owner.pid, 'SIGTERM')
214
+ let freed = await waitForPortFree(port, host, TERM_GRACE_MS)
215
+ if (!freed && isAlive(owner.pid)) {
216
+ log(chalk.dim(`PID ${owner.pid} 在 ${TERM_GRACE_MS}ms 内未退出,升级为 SIGKILL …`))
217
+ await terminate(owner.pid, 'SIGKILL')
218
+ freed = await waitForPortFree(port, host, KILL_GRACE_MS)
219
+ }
220
+ if (!freed) {
221
+ throw new Error(`无法释放端口 ${port}(PID ${owner.pid} 仍占用)。手动排查:${manualHint(port)}`)
222
+ }
223
+ log(chalk.green(`旧进程 PID ${owner.pid} 已结束,端口 ${port} 已释放。`))
224
+ }
225
+
226
+ /**
227
+ * 确保端口可用;被旧的 cardbuddy 实例占用时主动结束它。
228
+ *
229
+ * @param {object} opts
230
+ * @param {number} opts.port 目标端口
231
+ * @param {string} [opts.host] 绑定地址(默认 127.0.0.1)
232
+ * @param {boolean} [opts.tls] 本次是否以 HTTPS 启动(决定探测顺序)
233
+ * @param {string} [opts.token] DSL_DEV_TOKEN,探测时带上
234
+ * @param {'auto'|'force'|'off'} [opts.mode] 接管策略(DSL_DEV_PORT_TAKEOVER)
235
+ * @param {(msg: string) => void} [opts.log] 日志输出
236
+ * @returns {Promise<{ freed: Array<{pid:number,command:string}>, skipped: Array<{pid:number,command:string}> }>}
237
+ */
238
+ export async function ensurePortFree({
239
+ port,
240
+ host = '127.0.0.1',
241
+ tls = false,
242
+ token = '',
243
+ mode = 'auto',
244
+ log = (msg) => console.log(msg),
245
+ }) {
246
+ const result = { freed: [], skipped: [] }
247
+ if (mode === 'off') return result
248
+
249
+ const before = await probePort(port, host)
250
+ if (before.free) return result
251
+ if (before.code !== 'EADDRINUSE') {
252
+ throw new Error(`端口 ${port} 无法绑定(${before.code})。请检查权限,或用 FINMALL_CARD_PORT 换一个端口。`)
253
+ }
254
+
255
+ const owners = await findPortOwners(port)
256
+ if (!owners.length) {
257
+ throw new Error(`端口 ${port} 已被占用,但未能定位占用进程。手动排查:${manualHint(port)}`)
258
+ }
259
+
260
+ const identity = await probeService(port, { token, tls })
261
+ for (const owner of owners) {
262
+ const ours = identity.ours || OWN_COMMAND_RE.test(owner.command || '')
263
+ if (!ours && mode !== 'force') {
264
+ result.skipped.push(owner)
265
+ continue
266
+ }
267
+ log(
268
+ chalk.yellow(`端口 ${port} 已被占用`) +
269
+ chalk.dim(` → PID ${owner.pid}${owner.command ? ` ${owner.command}` : ''}`) +
270
+ chalk.dim(
271
+ ours
272
+ ? `(cardbuddy 旧实例${identity.ours ? `,${identity.detail}` : ''}),正在结束并接管…`
273
+ : '(非 cardbuddy 进程,force 接管)'
274
+ )
275
+ )
276
+ await terminateOwner(owner, { port, host, log })
277
+ result.freed.push(owner)
278
+ }
279
+
280
+ if (result.skipped.length) {
281
+ const list = result.skipped.map((o) => `PID ${o.pid}${o.command ? ` — ${o.command}` : ''}`).join('\n ')
282
+ throw new Error(
283
+ `端口 ${port} 被非 cardbuddy 进程占用,未自动结束:\n ${list}\n` +
284
+ `请手动处理(${manualHint(port)}),或用 FINMALL_CARD_PORT 换端口;确需强制接管设 DSL_DEV_PORT_TAKEOVER=force。`
285
+ )
286
+ }
287
+
288
+ const after = await probePort(port, host)
289
+ if (!after.free) {
290
+ throw new Error(`结束旧进程后端口 ${port} 仍被占用(${after.code})。手动排查:${manualHint(port)}`)
291
+ }
292
+ return result
293
+ }