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.
- package/README.md +21 -6
- package/dist/cli.js +17 -6
- package/dist/config.js +20 -2
- package/dist/portGuard.js +293 -0
- package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
- package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/server-core/_vendor/finmall-env.mjs +4 -4
- package/dist/server-core/ai-chat.js +3710 -0
- package/dist/server-core/assetProxy.js +3 -22
- package/dist/server-core/buildDataFromSchema.js +36 -4
- package/dist/server-core/cardFiles.js +95 -2
- package/dist/server-core/cardImage.js +14 -0
- package/dist/server-core/cardListCache.js +0 -17
- package/dist/server-core/cardSave.js +2 -0
- package/dist/server-core/cardValidate.js +3 -0
- package/dist/server-core/index.js +6 -5
- package/dist/server-core/logStream.js +1 -6
- package/dist/server-core/sliceUpload.js +8 -1
- package/dist/standalone.js +24 -8
- package/dist/vendored/App.vue +405 -125
- package/dist/vendored/api.js +4 -4
- package/dist/vendored/assets/svgIcons/icons.svg +28 -0
- package/dist/vendored/components/AiSettings.vue +392 -0
- package/dist/vendored/components/CardGallery.vue +51 -5
- package/dist/vendored/components/ChatComposer.vue +413 -0
- package/dist/vendored/components/ChatMessage.vue +383 -0
- package/dist/vendored/components/ChatPanel.vue +937 -0
- package/dist/vendored/components/ComponentPalette.vue +1 -1
- package/dist/vendored/components/DebugPanel.vue +732 -90
- package/dist/vendored/components/Dropdown.vue +4 -2
- package/dist/vendored/components/MarkdownBody.vue +64 -0
- package/dist/vendored/components/NewCardDialog.vue +132 -0
- package/dist/vendored/components/NumberStepper.vue +125 -0
- package/dist/vendored/components/ProgressSlider.vue +9 -1
- package/dist/vendored/components/RemotePreview.vue +1 -1
- package/dist/vendored/components/StructureTree.vue +2 -2
- package/dist/vendored/main.js +1 -0
- package/dist/vendored/runtime/adapter.js +1 -1
- package/dist/vendored/runtime/ai-api.js +165 -0
- package/dist/vendored/runtime/clipboard-node.js +0 -5
- package/dist/vendored/runtime/markdown.js +140 -0
- package/dist/vendored/skills/.manifest.json +1101 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
- package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
- package/dist/vendored/styles/ai-chat.css +1850 -0
- package/dist/vendored/styles/app.css +725 -213
- package/dist/vendored/styles/card-gallery.css +48 -10
- package/dist/vendored/styles/card-select.css +13 -6
- package/dist/vendored/styles/dropdown.css +1 -2
- package/dist/vendored/styles/json-tree.css +3 -3
- package/dist/vendored/styles/node-inspector.css +13 -289
- package/dist/vendored/styles/setters.css +48 -49
- package/dist/vendored/styles/structure-tree.css +6 -11
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-B9MOriNv.js +0 -80
- package/dist/preview-editor/assets/index-CwD14gm3.css +0 -1
- 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.
|
|
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.
|
|
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) |
|
|
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.
|
|
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`
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
28
|
+
// 加载环境(与 skills 同套约定:.env.cb 为共享配置,.env.cb.local 为个人覆盖层)。
|
|
29
29
|
// dotenv 默认不覆盖已存在变量,故主仓库由启动器注入的 DSL_DEV_PORT / FINMALL_BASE_URL 仍优先。
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|