@optima-chat/dev-skills 0.16.1 → 0.16.3
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/.claude/commands/logs.md +60 -3
- package/.claude/commands/trace-user.md +18 -6
- package/.claude/skills/cn-deploy/SKILL.md +5 -3
- package/.claude/skills/logs/SKILL.md +10 -2
- package/.claude/skills/reset-onboarding/SKILL.md +116 -0
- package/.codex/skills/cn-deploy/SKILL.md +5 -3
- package/.codex/skills/reset-onboarding/SKILL.md +116 -0
- package/AGENTS.md +11 -6
- package/README.md +73 -61
- package/bin/helpers/cn-deploy.ts +44 -37
- package/bin/helpers/db-utils.ts +105 -6
- package/bin/helpers/logs.ts +464 -31
- package/dist/bin/helpers/cn-deploy.js +43 -37
- package/dist/bin/helpers/db-utils.js +105 -6
- package/dist/bin/helpers/logs.js +410 -31
- package/package.json +1 -1
|
@@ -22,11 +22,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
22
22
|
* optima-cn-deploy --list # 列出全部可发服务
|
|
23
23
|
* optima-cn-deploy billing --vtag cn-v1.2.3 # stage 按版本 tag 构建(发版前验证)
|
|
24
24
|
* optima-cn-deploy billing --env prod --vtag cn-v1.2.3
|
|
25
|
-
* # cn-prod vtag
|
|
25
|
+
* # cn-prod vtag 发版:构建→迁移→digest 钉死部署。🔴 **中间没有人工卡点**——
|
|
26
|
+
* # 触发即一路跑到 cn-prod 部署完,没有"到云效控制台再审批一次"这一步。
|
|
27
|
+
* # 实测 2026-08-09:gateway-core / billing / user-auth / commerce-backend /
|
|
28
|
+
* # optima-scout 五条 *-cn-prod 流水线 stages 均为 build→migrate→deploy,
|
|
29
|
+
* # 无任何审批任务(aliyun devops GetPipeline 查 pipelineConfig.flow 可复核)。
|
|
30
|
+
* # ⇒ 唯一的决策点是敲下这条命令之前。
|
|
26
31
|
*
|
|
27
32
|
* 前置: aliyun CLI(profile 默认 aliyun-optima,可用 OPTIMA_ALIYUN_PROFILE 覆盖)+ gh 已登录。
|
|
28
|
-
* 流水线定义的单一信源在 optima-terraform yunxiao/(gen-pipelines.py)
|
|
29
|
-
*
|
|
33
|
+
* 流水线定义的单一信源在 optima-terraform yunxiao/(gen-pipelines.py)。本表只快照
|
|
34
|
+
* repo / SAE appId;pipelineId 不在此表 —— stage/prod 均按流水线名 ${svc}-cn-${env}
|
|
35
|
+
* 从云效 ListPipelines 实时解析,不再手工同步(见 #84)。
|
|
30
36
|
*/
|
|
31
37
|
const node_child_process_1 = require("node:child_process");
|
|
32
38
|
const ORG = '6a17b6282bf8b1184fc0e0c6';
|
|
@@ -34,30 +40,30 @@ const ENDPOINT = 'devops.cn-hangzhou.aliyuncs.com';
|
|
|
34
40
|
const PROFILE = process.env.OPTIMA_ALIYUN_PROFILE || 'aliyun-optima';
|
|
35
41
|
const CODEUP_BASE = `https://codeup.aliyun.com/${ORG}`;
|
|
36
42
|
const SERVICES = {
|
|
37
|
-
'agent-portal': {
|
|
43
|
+
'agent-portal': { repo: 'optima-portals', saeAppId: 'fe757f78-d18d-4480-9402-fe59d4721055', prodSaeAppId: '9211db38-a255-4393-ae92-7a7bb41b583d' },
|
|
38
44
|
// build-only:非 SAE 常驻(gateway-core 按 session 拉起的镜像)。release 段=解析 ACR digest
|
|
39
45
|
// → 回写 Infisical /services/gateway-core/ALIYUN_AGENT_RUNTIME_IMAGE(#807)→ 滚动重启 gateway-core。
|
|
40
|
-
// 无自身 saeAppId
|
|
41
|
-
'agent-runtime': {
|
|
42
|
-
'agentic-chat': {
|
|
43
|
-
'billing': {
|
|
44
|
-
'browser-backend': {
|
|
45
|
-
'commerce-backend': {
|
|
46
|
-
'commerce-rq-scheduler': {
|
|
47
|
-
'commerce-rq-worker': {
|
|
48
|
-
'gateway-core': {
|
|
49
|
-
'gw-admin': {
|
|
50
|
-
'kb-backend': {
|
|
51
|
-
'ops-portal': {
|
|
52
|
-
'optima-generation': {
|
|
53
|
-
'optima-generation-worker': {
|
|
54
|
-
'optima-scout': {
|
|
55
|
-
'optima-sentinel': {
|
|
56
|
-
'optima-sentinel-worker': {
|
|
57
|
-
'optima-skills': {
|
|
58
|
-
'user-auth': {
|
|
59
|
-
'user-auth-admin': {
|
|
60
|
-
'yzsgo-api': {
|
|
46
|
+
// 无自身 saeAppId(build-only 不跑 SAE ImageUrl 校验)。
|
|
47
|
+
'agent-runtime': { repo: 'optima-gateway', buildOnly: true },
|
|
48
|
+
'agentic-chat': { repo: 'agentic-chat', saeAppId: '6aea1ce1-f813-4e1c-8e97-d1ecb5398e37', prodSaeAppId: '6e290c73-a646-43ef-9da5-ad0b2e7eff73' },
|
|
49
|
+
'billing': { repo: 'optima-billing', saeAppId: '09d8e292-dc64-4af8-bce5-0a56cb666921', prodSaeAppId: '6c31cf82-8802-4d45-b6a2-e9d7c83ccce9' },
|
|
50
|
+
'browser-backend': { repo: 'optima-browser-use', saeAppId: '1fced3f6-a80a-41a4-8f23-e4d5a467f8eb', prodSaeAppId: 'eb782ed9-c468-4e81-a0fe-87e5b4264192' },
|
|
51
|
+
'commerce-backend': { repo: 'commerce-backend', saeAppId: '49d09808-508c-471a-9560-553c49a67f72', prodSaeAppId: 'd40597b6-a98c-4063-9297-8b9fdfa8add4' },
|
|
52
|
+
'commerce-rq-scheduler': { repo: 'commerce-backend', saeAppId: '1d2810ef-889e-4675-b6e6-a299e4722e68', prodSaeAppId: '762dd7a5-7fe8-41ed-9332-cfd31b0e087e' },
|
|
53
|
+
'commerce-rq-worker': { repo: 'commerce-backend', saeAppId: 'd661da87-6d24-40b8-93ed-e1c967d8abe2', prodSaeAppId: '2c9ee19a-5062-4205-9f5b-6ae3f8062a23' },
|
|
54
|
+
'gateway-core': { repo: 'optima-gateway', saeAppId: '9326b7ff-da52-48d6-86db-9c4a884be108', prodSaeAppId: 'a08ce23f-3d3e-4d89-a2cf-53c8adba614e' },
|
|
55
|
+
'gw-admin': { repo: 'optima-gateway', saeAppId: '90e0daf7-7910-46b8-b5bf-a0b8bcc60859', prodSaeAppId: 'c6bc5a78-b27f-46e2-825a-eaa338c23645' },
|
|
56
|
+
'kb-backend': { repo: 'kb-skills', saeAppId: 'c7f65160-9d9e-416e-9e36-5439010d2b2d', prodSaeAppId: '732dfc8c-ed78-43f7-a722-c614359ff1a1' },
|
|
57
|
+
'ops-portal': { repo: 'optima-portals', saeAppId: '41d0ee66-8402-4e72-bfda-8eba75d1270c', prodSaeAppId: '69e513fd-29bf-4355-a275-5eba82b21136' },
|
|
58
|
+
'optima-generation': { repo: 'optima-gen', saeAppId: '327856d5-8b18-4e15-bfd8-b8bd3b807ffa', prodSaeAppId: '868d1a69-12c9-4bb7-868b-3e89c26784b5' },
|
|
59
|
+
'optima-generation-worker': { repo: 'optima-gen', saeAppId: 'ad75b3b0-9ff4-443d-b0ef-81886bc7aa60', prodSaeAppId: '2adc941e-374b-406a-9863-e53d40bc9500' },
|
|
60
|
+
'optima-scout': { repo: 'optima-scout', saeAppId: 'bac2c3a4-90ce-49a6-81d5-caa38cb5c807', prodSaeAppId: 'f5bb7e82-e57c-4bd4-83ae-2c25f8d38647' },
|
|
61
|
+
'optima-sentinel': { repo: 'optima-sentinel', saeAppId: '3759db7b-4640-4a04-8436-4f241f0ec9d9', prodSaeAppId: 'e120e70c-b2c1-49ef-a66c-9016fd2d807d' },
|
|
62
|
+
'optima-sentinel-worker': { repo: 'optima-sentinel', saeAppId: '7c975caf-ebfb-4bb3-91c4-2a8c83d1d2e5', prodSaeAppId: 'e0f9b92c-00af-4ade-9b07-15bba48c14cc' },
|
|
63
|
+
'optima-skills': { repo: 'optima-skills', saeAppId: '90457be3-5eb3-4efc-a362-1788dcc51921', prodSaeAppId: '55a63ee7-fb75-46e4-99f3-20854a699237' },
|
|
64
|
+
'user-auth': { repo: 'user-auth', saeAppId: '36efc3a6-5b42-49fa-8db2-0865aa0c25d2', prodSaeAppId: 'd6fbf9de-fed6-4165-978f-7b3a0a456acc' },
|
|
65
|
+
'user-auth-admin': { repo: 'user-auth', saeAppId: '7feb9cc3-4731-4ea8-964c-4870a9e63afb', prodSaeAppId: '54093f99-37bf-4354-abd7-f6e76abdbcb6' },
|
|
66
|
+
'yzsgo-api': { repo: 'yzsgo', saeAppId: 'd0af7f66-ef66-4587-9e93-250d01ee3cf6', prodSaeAppId: '5a820992-e11b-404a-9e58-8f607d8e0c5f' },
|
|
61
67
|
};
|
|
62
68
|
function sh(cmd, args) {
|
|
63
69
|
try {
|
|
@@ -110,7 +116,8 @@ async function main() {
|
|
|
110
116
|
process.exit(1);
|
|
111
117
|
}
|
|
112
118
|
if (envName === 'prod') {
|
|
113
|
-
// prod = vtag 制:先 stage 同 tag 验证,再发 prod
|
|
119
|
+
// prod = vtag 制:先 stage 同 tag 验证,再发 prod。🔴 流水线内**没有**人工卡点,
|
|
120
|
+
// 下面这道 vtag 校验就是最后一道闸(实测见文件头注)。
|
|
114
121
|
if (!vtag) {
|
|
115
122
|
console.error('✗ --env prod 必须带 --vtag cn-vX.Y.Z(先 stage 同 tag 验证)');
|
|
116
123
|
process.exit(1);
|
|
@@ -152,17 +159,15 @@ async function main() {
|
|
|
152
159
|
process.exit(1);
|
|
153
160
|
}
|
|
154
161
|
console.log(`✓ mirror 已追平 ${svc.repo}@${ref} = ${ghSha.slice(0, 10)}`);
|
|
155
|
-
// 2. 触发(凭证由云效变量组供给,无需注入)。prod 流水线 id
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
process.exit(1);
|
|
163
|
-
}
|
|
164
|
-
pipelineId = hit.pipelineId;
|
|
162
|
+
// 2. 触发(凭证由云效变量组供给,无需注入)。stage/prod 流水线 id 均按名 `${svc}-cn-${env}`
|
|
163
|
+
// 从云效实时解析、不硬编码,根除与 optima-terraform cn-run.py 手工 PIPELINES 表漂移(#84)。
|
|
164
|
+
const lp = devops('ListPipelines', { maxResults: '100' });
|
|
165
|
+
const hit = (lp.pipelines || []).find((p) => p.pipelineName === `${svcName}-cn-${envName}`);
|
|
166
|
+
if (!hit) {
|
|
167
|
+
console.error(`✗ 云效无 ${svcName}-cn-${envName} 流水线`);
|
|
168
|
+
process.exit(1);
|
|
165
169
|
}
|
|
170
|
+
const pipelineId = hit.pipelineId;
|
|
166
171
|
const kv = { pipelineId: String(pipelineId) };
|
|
167
172
|
if (vtag) {
|
|
168
173
|
kv.params = JSON.stringify({
|
|
@@ -182,7 +187,8 @@ async function main() {
|
|
|
182
187
|
console.log(`▶ ${svcName} run#${runId} 已启动 (ref=${ref}, env=${envName})`);
|
|
183
188
|
console.log(` https://flow.aliyun.com/pipelines/${pipelineId}/current`);
|
|
184
189
|
if (envName === 'prod') {
|
|
185
|
-
console.log('
|
|
190
|
+
console.log('🔴 cn-prod:流水线**没有**人工卡点 —— 构建→迁移→digest 部署会一路自动走完,');
|
|
191
|
+
console.log(' 不要去云效控制台等『发布审批』(那一步不存在)。要中止只能在上面的链接里手动取消 run。');
|
|
186
192
|
}
|
|
187
193
|
if (noWait)
|
|
188
194
|
return;
|
|
@@ -48,6 +48,7 @@ exports.readBuildboxPwFile = readBuildboxPwFile;
|
|
|
48
48
|
exports.getCnInfisicalToken = getCnInfisicalToken;
|
|
49
49
|
exports.getCnSecrets = getCnSecrets;
|
|
50
50
|
exports.parseDatabaseUrl = parseDatabaseUrl;
|
|
51
|
+
exports.classifyTunnelTarget = classifyTunnelTarget;
|
|
51
52
|
exports.ensureTunnel = ensureTunnel;
|
|
52
53
|
exports.queryDB = queryDB;
|
|
53
54
|
exports.connectCnDB = connectCnDB;
|
|
@@ -350,6 +351,79 @@ function isTunnelProcessOnPort(localPort) {
|
|
|
350
351
|
}
|
|
351
352
|
});
|
|
352
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* 隧道进程 cmdline 指向的 RDS host 与期望的 `dbHost` 是否一致(#76)。
|
|
356
|
+
*
|
|
357
|
+
* `isTunnelProcessOnPort` + `isTunnelHealthy` 只能回答「端口上是我们的隧道且 postgres
|
|
358
|
+
* 应答」—— **答不了「通向哪个库」**:连到错误实例的隧道同样 ssh + 同样 pg_isready 通。
|
|
359
|
+
* 注册表一旦出现两个 host 指向同一端口(见 pickTunnelPort 注释),warm reuse 就会把
|
|
360
|
+
* cn-prod 的查询送进 cn-stage 的隧道;两库账号密码不同才会报 auth failed 暴露出来,
|
|
361
|
+
* 同名账号则是**静默连错环境**。故复用前解析 cmdline 里的转发目标。
|
|
362
|
+
*
|
|
363
|
+
* 两种隧道形态的 cmdline(**均已在本机实测**,勿凭文档猜):
|
|
364
|
+
* ssh / cn-ssh:`ssh … -L [bind:]<localPort>:<dbHost>:5432 root@<bastion>`
|
|
365
|
+
* SSM :持端口的是 `session-manager-plugin`(不是 `aws` CLI),argv 里是
|
|
366
|
+
* StartSession 请求 **JSON**:
|
|
367
|
+
* `… StartSession {"Target":"i-…","DocumentName":"AWS-StartPortForwarding…",
|
|
368
|
+
* "Parameters": {"host": ["<dbHost>"], "portNumber": ["5432"],
|
|
369
|
+
* "localPortNumber": ["<localPort>"]}} https://ssm.…`
|
|
370
|
+
*
|
|
371
|
+
* **按 localPort 锚定**:一个 ssh 进程可以带多条 `-L`(工程师自建的多路转发也会被
|
|
372
|
+
* `isTunnelProcessOnPort` 认作「我们的隧道」)。只要 cmdline 里任一条 `-L` 命中 dbHost
|
|
373
|
+
* 就判 match,会把「25432 实际通向 stage、25433 才通向 prod」的进程误判为可复用 →
|
|
374
|
+
* 正好造成本 issue 要防的连错库。故只认**转发自本端口**的那一条。
|
|
375
|
+
*
|
|
376
|
+
* 返回 `unknown`(读不到 cmdline / 格式不认识)时**保守回退**:宁可偶尔多复用一次,
|
|
377
|
+
* 也不因误判杀掉正常 warm reuse;`mismatch` 才是确凿证据,一律拒绝复用。注意
|
|
378
|
+
* `unknown` 单独不足以防「注册表已存在重复端口」,那由 ensureTunnel 的 dup 判据兜底。
|
|
379
|
+
*/
|
|
380
|
+
function classifyTunnelTarget(args, dbHost, localPort) {
|
|
381
|
+
const lp = localPort === undefined ? '\\d+' : String(localPort);
|
|
382
|
+
const targets = [];
|
|
383
|
+
// ssh:`-L [bind_address:]<localPort>:<host>:<remotePort>`(bind_address 可选是 ssh 合法写法)
|
|
384
|
+
for (const m of args.matchAll(new RegExp(`-L\\s*(?:[A-Za-z0-9._-]+:)?${lp}:([A-Za-z0-9._-]+):\\d+`, 'g'))) {
|
|
385
|
+
targets.push(m[1]);
|
|
386
|
+
}
|
|
387
|
+
// SSM:请求 JSON 里的 host;带 localPortNumber 时同样按本端口锚定
|
|
388
|
+
const ssmHost = args.match(/"host"\s*:\s*\[\s*"([A-Za-z0-9._-]+)"/);
|
|
389
|
+
if (ssmHost) {
|
|
390
|
+
const ssmLocal = args.match(/"localPortNumber"\s*:\s*\[\s*"(\d+)"/);
|
|
391
|
+
if (localPort === undefined || !ssmLocal || ssmLocal[1] === String(localPort))
|
|
392
|
+
targets.push(ssmHost[1]);
|
|
393
|
+
}
|
|
394
|
+
if (targets.length === 0)
|
|
395
|
+
return 'unknown';
|
|
396
|
+
const want = dbHost.toLowerCase();
|
|
397
|
+
return targets.some(t => t.toLowerCase() === want) ? 'match' : 'mismatch';
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* 端口上的隧道进程是否通向 `dbHost`(聚合多 pid:任一 match 即 match)。
|
|
401
|
+
* 只读**隧道进程**(comm=ssh / session-manager)的 argv —— `lsof -ti:PORT` 同时会列出
|
|
402
|
+
* 连到该端口的**客户端**(psql / pg_isready),其 argv 里若恰好含 `host=…` 会造成假 mismatch。
|
|
403
|
+
*/
|
|
404
|
+
function tunnelTargetOnPort(localPort, dbHost) {
|
|
405
|
+
let sawMismatch = false;
|
|
406
|
+
for (const pid of portPids(localPort)) {
|
|
407
|
+
let comm = '';
|
|
408
|
+
let args = '';
|
|
409
|
+
try {
|
|
410
|
+
const out = (0, child_process_1.execSync)(`ps -o comm=,args= -p ${pid}`, { encoding: 'utf-8' }).trim();
|
|
411
|
+
comm = out.split(/\s+/)[0] ?? '';
|
|
412
|
+
args = out;
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
continue; // 进程刚退出
|
|
416
|
+
}
|
|
417
|
+
if (comm !== 'ssh' && !comm.includes('session-manager'))
|
|
418
|
+
continue; // 客户端进程,跳过
|
|
419
|
+
const verdict = classifyTunnelTarget(args, dbHost, localPort);
|
|
420
|
+
if (verdict === 'match')
|
|
421
|
+
return 'match';
|
|
422
|
+
if (verdict === 'mismatch')
|
|
423
|
+
sawMismatch = true;
|
|
424
|
+
}
|
|
425
|
+
return sawMismatch ? 'mismatch' : 'unknown';
|
|
426
|
+
}
|
|
353
427
|
function killOrphanTunnel(localPort) {
|
|
354
428
|
// 只杀我们的隧道进程 —— 端口上若是别人(docker-proxy/postgres),kill -9 是破坏性的。
|
|
355
429
|
for (const pid of portPids(localPort)) {
|
|
@@ -482,14 +556,23 @@ function writeTunnelRegistry(reg) {
|
|
|
482
556
|
fs.mkdirSync(`${os.homedir()}/.cache/optima-dev-skills`, { recursive: true });
|
|
483
557
|
fs.writeFileSync(TUNNEL_REGISTRY, JSON.stringify(reg, null, 2));
|
|
484
558
|
}
|
|
485
|
-
/**
|
|
486
|
-
|
|
559
|
+
/**
|
|
560
|
+
* First free local port, preferring the registry's previous assignment.
|
|
561
|
+
*
|
|
562
|
+
* `reserved` = 注册表里**其它 host** 已占用的端口(#76)。原实现只看「此刻空闲」:
|
|
563
|
+
* 隧道进程死掉后端口释放,下一个 host 扫描到同一个 25432 就写进注册表 → 多个 host
|
|
564
|
+
* 指向同一端口(实遇 4 个 RDS 里 3 个都是 25432),之后任一条隧道活着都会被其它
|
|
565
|
+
* host 误复用 → 连错库。排除其它 host 已认领的端口,注册表由构造保持单射。
|
|
566
|
+
*/
|
|
567
|
+
function pickTunnelPort(preferred, reserved = new Set()) {
|
|
487
568
|
const candidates = preferred ? [preferred] : [];
|
|
488
569
|
for (let p = TUNNEL_PORT_SCAN_BASE; p < TUNNEL_PORT_SCAN_BASE + TUNNEL_PORT_SCAN_LIMIT; p++) {
|
|
489
570
|
if (p !== preferred)
|
|
490
571
|
candidates.push(p);
|
|
491
572
|
}
|
|
492
573
|
for (const p of candidates) {
|
|
574
|
+
if (reserved.has(p))
|
|
575
|
+
continue;
|
|
493
576
|
if (portPids(p).length === 0 && !isPortResponding(p))
|
|
494
577
|
return p;
|
|
495
578
|
}
|
|
@@ -508,15 +591,31 @@ function ensureTunnel(dbHost, via = 'aws') {
|
|
|
508
591
|
throw new Error('ensureTunnel: dbHost contains unexpected characters (refusing to build tunnel command)');
|
|
509
592
|
const reg = readTunnelRegistry();
|
|
510
593
|
const known = reg[dbHost];
|
|
594
|
+
// #76:端口同时被注册表里其它 host 认领 = 注册表已损坏(历史遗留缓存就是这个状态)。
|
|
595
|
+
// 此时若目标又验不出来(unknown,如 argv 格式不认识),不能乐观复用——否则那条路径
|
|
596
|
+
// 永远早返回、不写注册表,重复项永不自愈(AWS/SSM 默认路径尤其吃这个亏)。
|
|
597
|
+
const dupClaimed = known !== undefined
|
|
598
|
+
&& Object.entries(reg).some(([h, p]) => h !== dbHost && p === known);
|
|
511
599
|
if (known !== undefined && isTunnelProcessOnPort(known)) {
|
|
512
|
-
|
|
600
|
+
// #76:健康 ≠ 通向正确的库 —— 先验转发目标,确凿指向别的 RDS 就别复用(也别杀,
|
|
601
|
+
// 那是另一个 host 正在用的隧道),换个端口自己建。
|
|
602
|
+
const verdict = tunnelTargetOnPort(known, dbHost);
|
|
603
|
+
if (verdict === 'mismatch' || (verdict === 'unknown' && dupClaimed)) {
|
|
604
|
+
console.log(`! Tunnel on port ${known} may forward to a different RDS host, not reusing (#76)`);
|
|
605
|
+
}
|
|
606
|
+
else if (isTunnelHealthy(known)) {
|
|
513
607
|
return known; // warm reuse — no cold start
|
|
514
|
-
|
|
515
|
-
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
console.log(`! Tunnel on port ${known} not responding (zombie), replacing...`);
|
|
611
|
+
killOrphanTunnel(known);
|
|
612
|
+
}
|
|
516
613
|
}
|
|
517
614
|
// Foreign process on the recorded port (e.g. a local Docker PG) → pick a
|
|
518
615
|
// different port instead of talking to whatever squats there.
|
|
519
|
-
|
|
616
|
+
// #76:同时排除注册表里其它 host 认领的端口,避免两个 host 收敛到同一端口。
|
|
617
|
+
const reserved = new Set(Object.entries(reg).filter(([h]) => h !== dbHost).map(([, p]) => p));
|
|
618
|
+
const port = pickTunnelPort(known, reserved);
|
|
520
619
|
if (via === 'cn-buildbox') {
|
|
521
620
|
setupCnSSHTunnel(dbHost, port);
|
|
522
621
|
}
|