@optima-chat/dev-skills 0.16.0 → 0.16.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.
@@ -212,6 +212,8 @@ optima-query-db gateway-core "SELECT key, value FROM app_configs" cn-prod
212
212
  | `INFISICAL_CN_EMAIL` + `INFISICAL_CN_PASSWORD` | 登录 cn Infisical 取数据库凭证 | 1Password「Infisical cn-prod admin (secrets-cn.optima.chat)」 |
213
213
  | `OPTIMA_CN_BUILDBOX_PASSWORD` | 经 buildbox 跳板建 RDS 隧道(本机已有健康隧道时可省) | 1Password「Aliyun cn-prod buildbox ECS (root)」 |
214
214
 
215
+ > **两条独立路径**:① 从上表的 1P 取值 `export` 到当前 session(不落盘,推荐;有 `op` CLI 更佳);或 ② 若你**自己**维护本地明文文件 `~/.infisical_cn_creds`(每行 `export KEY=val`)和 `~/.buildbox_pw`(纯密码一行),工具会自动读取、免手动 `export`/`source`(路径分别可用 `INFISICAL_CN_CREDS_FILE` / `OPTIMA_CN_BUILDBOX_PW_FILE` 覆盖)。②是明文落盘的便捷取舍(`chmod 600`,权限宽于 600 工具会打警告),非默认做法、别把 1P 密码抄进文件;没放这些文件时回退走 env(①)。creds 文件解析**不是 shell**:只认最朴素的 `KEY=值` 与成对引号,无引号值别写行尾注释。
216
+
215
217
  ## 🔧 技术架构
216
218
 
217
219
  ### Infisical 配置(v0.7.0+)
@@ -77,8 +77,10 @@ optima-show-env agentic-chat stage
77
77
  - `shopify-backend` - Shopify 店铺管理服务
78
78
 
79
79
  **支持的环境**:
80
- - `stage` - Stage 预发布环境
81
- - `prod` - Prod 生产环境
80
+ - `stage` - Stage 预发布环境(AWS Infisical)
81
+ - `prod` - Prod 生产环境(AWS Infisical)
82
+ - `cn-stage` - 阿里云 cn-stage(读 cn Infisical,需凭据,见「故障排查 · 问题 2b」)
83
+ - `cn-prod` - 阿里云 cn-prod(同上)
82
84
 
83
85
  ### 3. 使用过滤和选项
84
86
 
@@ -198,6 +200,17 @@ npm install -g @optima-chat/dev-skills@latest
198
200
  - 运行 `gh auth login` 登录 GitHub
199
201
  - 确认有仓库访问权限
200
202
 
203
+ ### 问题 2b:cn-stage / cn-prod 报「需要 INFISICAL_CN_EMAIL / PASSWORD」
204
+
205
+ `cn-stage` / `cn-prod` 读的是**阿里云 cn Infisical**(`secrets-cn.optima.chat`,与 AWS 那套完全独立),需要 admin 账密两个环境变量。**两条独立路径,按你的安全取向二选一**:
206
+
207
+ - **走 1Password(推荐,密码不落盘)**:从「Infisical cn-prod admin (secrets-cn.optima.chat)」取值,`export INFISICAL_CN_EMAIL=... INFISICAL_CN_PASSWORD=...` 到当前 session(装了 `op` CLI 可 `op run` / `op read` 注入,用完即散)。
208
+ - **本地凭据文件(便捷,安全性较弱)**:若你**自己**维护 `~/.infisical_cn_creds`(每行 `export KEY=val`),本工具会自动读取、免手动 `source`;路径用 `INFISICAL_CN_CREDS_FILE` 覆盖。⚠️ 明文落盘(务必 `chmod 600`),是「省事 vs 暴露面」的取舍——**不是让你把 1P 密码抄进文件当默认做法**,只是给已经这么存的人一个自动读取入口。
209
+
210
+ > 两者互不依赖:有本地文件就自动用,没有就回退读 env(即走 1P 那条)。`optima-query-db cn-stage/cn-prod` 的 buildbox 密码同理——`export OPTIMA_CN_BUILDBOX_PASSWORD=...`,或本地 `~/.buildbox_pw`(纯密码一行,自动读取;`OPTIMA_CN_BUILDBOX_PW_FILE` 可覆盖路径);密码在 1P「Aliyun cn-prod buildbox ECS (root)」。
211
+ >
212
+ > ⚠️ creds 文件的解析**不是 shell**:只认每行一条最朴素的 `KEY=值`(可带 `export `),值要么整体用成对引号包住、要么不含空格;引号值后的内容(如行尾注释)会被忽略,**无引号值别写行尾注释**(`#` 会被当成值的一部分)。文件权限宽于 600 时工具会打警告。
213
+
201
214
  ### 问题 3:服务不存在
202
215
 
203
216
  **错误信息**: "Unknown service 'xxx'"
@@ -109,15 +109,75 @@ function curlJson(args: string[]): any {
109
109
  catch { throw new Error(`cn Infisical: non-JSON response: ${String(out).slice(0, 200)}`); }
110
110
  }
111
111
 
112
+ /** 明文密码文件权限比 600 宽(组/其他用户可读)时往 stderr 警告一行。Windows 无此语义,跳过。 */
113
+ function warnIfLoosePerms(file: string): void {
114
+ if (process.platform === 'win32') return;
115
+ try {
116
+ const mode = fs.statSync(file).mode & 0o777;
117
+ if (mode & 0o077) console.error(`⚠️ ${file} 权限 ${mode.toString(8).padStart(3, '0')}(同机其他用户可读)——建议 chmod 600 ${file}`);
118
+ } catch { /* stat 失败不阻塞主流程 */ }
119
+ }
120
+
121
+ /**
122
+ * 若 creds 文件存在,把其中 `export KEY=val` / `KEY=val` 行注入 process.env(已存在的键不覆盖)。
123
+ * 免去每次手动 `source ~/.infisical_cn_creds`;文件不存在则静默跳过,回退到调用方的 env 检查。
124
+ * ⚠️ 不是 shell:只认最朴素的每行一条 `KEY=值`。值以引号开头时取到配对引号为止(其后内容
125
+ * 忽略,行尾注释因此无害);引号未闭合的行警告并跳过(真 shell 会语法错,静默注入 `"abc`
126
+ * 这类错值只会换来下游 401、更难查);无引号值取到行尾原样保留(别写行尾注释,`#` 会进值)。
127
+ * 导出仅供测试。
128
+ */
129
+ const loadedCredsFiles = new Set<string>(); // 同一文件只加载/警告一次(getCnInfisicalToken 与 setupCnSSHTunnel 都会调)
130
+ export function loadCredsFileIntoEnv(file: string): void {
131
+ if (loadedCredsFiles.has(file)) return;
132
+ loadedCredsFiles.add(file);
133
+ let content: string;
134
+ try { content = fs.readFileSync(file, 'utf-8'); }
135
+ catch { return; } // 文件不存在/不可读 = 无操作,保持原有「读 env」行为
136
+ warnIfLoosePerms(file);
137
+ const lines = content.split(/\r?\n/);
138
+ for (let i = 0; i < lines.length; i++) {
139
+ const m = lines[i].match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
140
+ if (!m) continue;
141
+ const key = m[1];
142
+ if (process.env[key] !== undefined) continue; // 已有 env 优先,不覆盖
143
+ let val = m[2].trim();
144
+ const q = val[0];
145
+ if (q === '"' || q === "'") {
146
+ const close = val.indexOf(q, 1);
147
+ if (close === -1) {
148
+ console.error(`⚠️ ${file} 第 ${i + 1} 行(${key}=)引号未闭合,已跳过该行`);
149
+ continue;
150
+ }
151
+ val = val.slice(1, close); // 配对引号截取,忽略其后(如行尾注释)
152
+ }
153
+ process.env[key] = val;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * 读 buildbox 密码文件(纯密码一行;默认 ~/.buildbox_pw,OPTIMA_CN_BUILDBOX_PW_FILE 可覆盖)。
159
+ * 文件不存在/为空 → undefined,回退到调用方报错。导出仅供测试。
160
+ */
161
+ export function readBuildboxPwFile(): string | undefined {
162
+ const file = process.env.OPTIMA_CN_BUILDBOX_PW_FILE || `${os.homedir()}/.buildbox_pw`;
163
+ let pw: string;
164
+ try { pw = fs.readFileSync(file, 'utf-8').trim(); }
165
+ catch { return undefined; }
166
+ warnIfLoosePerms(file); // 读到文件就查权限,空文件也警告
167
+ if (!pw) return undefined;
168
+ return pw;
169
+ }
170
+
112
171
  /**
113
172
  * 认证 cn Infisical(admin email/password → org-scoped token)。
114
173
  * ⚠️ 字段名坑:login 返回 `accessToken`(不是 token),select-organization 才返回 `token`。
115
174
  */
116
175
  export function getCnInfisicalToken(): string {
176
+ loadCredsFileIntoEnv(process.env.INFISICAL_CN_CREDS_FILE || `${os.homedir()}/.infisical_cn_creds`);
117
177
  const email = process.env.INFISICAL_CN_EMAIL;
118
178
  const password = process.env.INFISICAL_CN_PASSWORD;
119
179
  if (!email || !password) {
120
- throw new Error('cn Infisical 需要 INFISICAL_CN_EMAIL + INFISICAL_CN_PASSWORD 环境变量(admin user,1P "Infisical cn-prod admin (secrets-cn.optima.chat)")。见 optima-dev-skills#21。');
180
+ throw new Error('cn Infisical 需要 INFISICAL_CN_EMAIL + INFISICAL_CN_PASSWORD。① 1Password "Infisical cn-prod admin (secrets-cn.optima.chat)" 取值 export(不落盘);或 ② 本工具自动读取本地文件 ~/.infisical_cn_creds(每行 `export KEY=val`,INFISICAL_CN_CREDS_FILE 可覆盖)。见 optima-dev-skills#21。');
121
181
  }
122
182
  const login = curlJson([
123
183
  '-X', 'POST', `${CN_INFISICAL_URL}/api/v3/auth/login`,
@@ -239,6 +299,79 @@ function isTunnelProcessOnPort(localPort: number): boolean {
239
299
  });
240
300
  }
241
301
 
302
+ /**
303
+ * 隧道进程 cmdline 指向的 RDS host 与期望的 `dbHost` 是否一致(#76)。
304
+ *
305
+ * `isTunnelProcessOnPort` + `isTunnelHealthy` 只能回答「端口上是我们的隧道且 postgres
306
+ * 应答」—— **答不了「通向哪个库」**:连到错误实例的隧道同样 ssh + 同样 pg_isready 通。
307
+ * 注册表一旦出现两个 host 指向同一端口(见 pickTunnelPort 注释),warm reuse 就会把
308
+ * cn-prod 的查询送进 cn-stage 的隧道;两库账号密码不同才会报 auth failed 暴露出来,
309
+ * 同名账号则是**静默连错环境**。故复用前解析 cmdline 里的转发目标。
310
+ *
311
+ * 两种隧道形态的 cmdline(**均已在本机实测**,勿凭文档猜):
312
+ * ssh / cn-ssh:`ssh … -L [bind:]<localPort>:<dbHost>:5432 root@<bastion>`
313
+ * SSM :持端口的是 `session-manager-plugin`(不是 `aws` CLI),argv 里是
314
+ * StartSession 请求 **JSON**:
315
+ * `… StartSession {"Target":"i-…","DocumentName":"AWS-StartPortForwarding…",
316
+ * "Parameters": {"host": ["<dbHost>"], "portNumber": ["5432"],
317
+ * "localPortNumber": ["<localPort>"]}} https://ssm.…`
318
+ *
319
+ * **按 localPort 锚定**:一个 ssh 进程可以带多条 `-L`(工程师自建的多路转发也会被
320
+ * `isTunnelProcessOnPort` 认作「我们的隧道」)。只要 cmdline 里任一条 `-L` 命中 dbHost
321
+ * 就判 match,会把「25432 实际通向 stage、25433 才通向 prod」的进程误判为可复用 →
322
+ * 正好造成本 issue 要防的连错库。故只认**转发自本端口**的那一条。
323
+ *
324
+ * 返回 `unknown`(读不到 cmdline / 格式不认识)时**保守回退**:宁可偶尔多复用一次,
325
+ * 也不因误判杀掉正常 warm reuse;`mismatch` 才是确凿证据,一律拒绝复用。注意
326
+ * `unknown` 单独不足以防「注册表已存在重复端口」,那由 ensureTunnel 的 dup 判据兜底。
327
+ */
328
+ export function classifyTunnelTarget(
329
+ args: string,
330
+ dbHost: string,
331
+ localPort?: number,
332
+ ): 'match' | 'mismatch' | 'unknown' {
333
+ const lp = localPort === undefined ? '\\d+' : String(localPort);
334
+ const targets: string[] = [];
335
+ // ssh:`-L [bind_address:]<localPort>:<host>:<remotePort>`(bind_address 可选是 ssh 合法写法)
336
+ for (const m of args.matchAll(new RegExp(`-L\\s*(?:[A-Za-z0-9._-]+:)?${lp}:([A-Za-z0-9._-]+):\\d+`, 'g'))) {
337
+ targets.push(m[1]);
338
+ }
339
+ // SSM:请求 JSON 里的 host;带 localPortNumber 时同样按本端口锚定
340
+ const ssmHost = args.match(/"host"\s*:\s*\[\s*"([A-Za-z0-9._-]+)"/);
341
+ if (ssmHost) {
342
+ const ssmLocal = args.match(/"localPortNumber"\s*:\s*\[\s*"(\d+)"/);
343
+ if (localPort === undefined || !ssmLocal || ssmLocal[1] === String(localPort)) targets.push(ssmHost[1]);
344
+ }
345
+ if (targets.length === 0) return 'unknown';
346
+ const want = dbHost.toLowerCase();
347
+ return targets.some(t => t.toLowerCase() === want) ? 'match' : 'mismatch';
348
+ }
349
+
350
+ /**
351
+ * 端口上的隧道进程是否通向 `dbHost`(聚合多 pid:任一 match 即 match)。
352
+ * 只读**隧道进程**(comm=ssh / session-manager)的 argv —— `lsof -ti:PORT` 同时会列出
353
+ * 连到该端口的**客户端**(psql / pg_isready),其 argv 里若恰好含 `host=…` 会造成假 mismatch。
354
+ */
355
+ function tunnelTargetOnPort(localPort: number, dbHost: string): 'match' | 'mismatch' | 'unknown' {
356
+ let sawMismatch = false;
357
+ for (const pid of portPids(localPort)) {
358
+ let comm = '';
359
+ let args = '';
360
+ try {
361
+ const out = execSync(`ps -o comm=,args= -p ${pid}`, { encoding: 'utf-8' }).trim();
362
+ comm = out.split(/\s+/)[0] ?? '';
363
+ args = out;
364
+ } catch {
365
+ continue; // 进程刚退出
366
+ }
367
+ if (comm !== 'ssh' && !comm.includes('session-manager')) continue; // 客户端进程,跳过
368
+ const verdict = classifyTunnelTarget(args, dbHost, localPort);
369
+ if (verdict === 'match') return 'match';
370
+ if (verdict === 'mismatch') sawMismatch = true;
371
+ }
372
+ return sawMismatch ? 'mismatch' : 'unknown';
373
+ }
374
+
242
375
  function killOrphanTunnel(localPort: number): void {
243
376
  // 只杀我们的隧道进程 —— 端口上若是别人(docker-proxy/postgres),kill -9 是破坏性的。
244
377
  for (const pid of portPids(localPort)) {
@@ -318,8 +451,10 @@ function setupSSMTunnel(dbHost: string, localPort: number): void {
318
451
  * 密码经 SSHPASS 环境变量传给 `sshpass -e`:不进命令行(ps 不可见)、不经 shell 引号展开。
319
452
  */
320
453
  function setupCnSSHTunnel(dbHost: string, localPort: number): void {
321
- const pw = process.env.OPTIMA_CN_BUILDBOX_PASSWORD;
322
- if (!pw) throw new Error('cn DB 隧道需要 OPTIMA_CN_BUILDBOX_PASSWORD 环境变量(buildbox root 密码,1P "Aliyun cn-prod buildbox ECS (root)")。见 optima-dev-skills#21。');
454
+ // creds 文件里也可能放 OPTIMA_CN_BUILDBOX_PASSWORD——自己加载一次(幂等),不依赖 getCnInfisicalToken 恰好先跑
455
+ loadCredsFileIntoEnv(process.env.INFISICAL_CN_CREDS_FILE || `${os.homedir()}/.infisical_cn_creds`);
456
+ const pw = process.env.OPTIMA_CN_BUILDBOX_PASSWORD || readBuildboxPwFile();
457
+ if (!pw) throw new Error('cn DB 隧道需要 OPTIMA_CN_BUILDBOX_PASSWORD。① 从 1Password "Aliyun cn-prod buildbox ECS (root)" 取值 export;或 ② 本工具自动读取本地文件 ~/.buildbox_pw(纯密码一行,OPTIMA_CN_BUILDBOX_PW_FILE 可覆盖路径)。见 optima-dev-skills#21。');
323
458
  try { execSync('command -v sshpass', { stdio: 'ignore' }); }
324
459
  catch { throw new Error('sshpass not found — cn buildbox 隧道需要(apt install sshpass / brew install esolitos/ipa/sshpass)。Windows 用 WSL。'); }
325
460
 
@@ -367,13 +502,21 @@ function writeTunnelRegistry(reg: Record<string, number>): void {
367
502
  fs.writeFileSync(TUNNEL_REGISTRY, JSON.stringify(reg, null, 2));
368
503
  }
369
504
 
370
- /** First free local port, preferring the registry's previous assignment. */
371
- function pickTunnelPort(preferred?: number): number {
505
+ /**
506
+ * First free local port, preferring the registry's previous assignment.
507
+ *
508
+ * `reserved` = 注册表里**其它 host** 已占用的端口(#76)。原实现只看「此刻空闲」:
509
+ * 隧道进程死掉后端口释放,下一个 host 扫描到同一个 25432 就写进注册表 → 多个 host
510
+ * 指向同一端口(实遇 4 个 RDS 里 3 个都是 25432),之后任一条隧道活着都会被其它
511
+ * host 误复用 → 连错库。排除其它 host 已认领的端口,注册表由构造保持单射。
512
+ */
513
+ function pickTunnelPort(preferred: number | undefined, reserved: ReadonlySet<number> = new Set()): number {
372
514
  const candidates = preferred ? [preferred] : [];
373
515
  for (let p = TUNNEL_PORT_SCAN_BASE; p < TUNNEL_PORT_SCAN_BASE + TUNNEL_PORT_SCAN_LIMIT; p++) {
374
516
  if (p !== preferred) candidates.push(p);
375
517
  }
376
518
  for (const p of candidates) {
519
+ if (reserved.has(p)) continue;
377
520
  if (portPids(p).length === 0 && !isPortResponding(p)) return p;
378
521
  }
379
522
  throw new Error(`No free local port in ${TUNNEL_PORT_SCAN_BASE}-${TUNNEL_PORT_SCAN_BASE + TUNNEL_PORT_SCAN_LIMIT - 1} for the DB tunnel`);
@@ -392,15 +535,33 @@ export function ensureTunnel(dbHost: string, via: 'aws' | 'cn-buildbox' = 'aws')
392
535
  const reg = readTunnelRegistry();
393
536
  const known = reg[dbHost];
394
537
 
538
+ // #76:端口同时被注册表里其它 host 认领 = 注册表已损坏(历史遗留缓存就是这个状态)。
539
+ // 此时若目标又验不出来(unknown,如 argv 格式不认识),不能乐观复用——否则那条路径
540
+ // 永远早返回、不写注册表,重复项永不自愈(AWS/SSM 默认路径尤其吃这个亏)。
541
+ const dupClaimed = known !== undefined
542
+ && Object.entries(reg).some(([h, p]) => h !== dbHost && p === known);
543
+
395
544
  if (known !== undefined && isTunnelProcessOnPort(known)) {
396
- if (isTunnelHealthy(known)) return known; // warm reuse — no cold start
397
- console.log(`! Tunnel on port ${known} not responding (zombie), replacing...`);
398
- killOrphanTunnel(known);
545
+ // #76:健康 通向正确的库 —— 先验转发目标,确凿指向别的 RDS 就别复用(也别杀,
546
+ // 那是另一个 host 正在用的隧道),换个端口自己建。
547
+ const verdict = tunnelTargetOnPort(known, dbHost);
548
+ if (verdict === 'mismatch' || (verdict === 'unknown' && dupClaimed)) {
549
+ console.log(`! Tunnel on port ${known} may forward to a different RDS host, not reusing (#76)`);
550
+ } else if (isTunnelHealthy(known)) {
551
+ return known; // warm reuse — no cold start
552
+ } else {
553
+ console.log(`! Tunnel on port ${known} not responding (zombie), replacing...`);
554
+ killOrphanTunnel(known);
555
+ }
399
556
  }
400
557
 
401
558
  // Foreign process on the recorded port (e.g. a local Docker PG) → pick a
402
559
  // different port instead of talking to whatever squats there.
403
- const port = pickTunnelPort(known);
560
+ // #76:同时排除注册表里其它 host 认领的端口,避免两个 host 收敛到同一端口。
561
+ const reserved = new Set(
562
+ Object.entries(reg).filter(([h]) => h !== dbHost).map(([, p]) => p),
563
+ );
564
+ const port = pickTunnelPort(known, reserved);
404
565
  if (via === 'cn-buildbox') {
405
566
  setupCnSSHTunnel(dbHost, port);
406
567
  } else if ((process.env.OPTIMA_DB_TUNNEL || 'ssm').toLowerCase() === 'ssh') {
@@ -43,9 +43,12 @@ exports.getGitHubVariable = getGitHubVariable;
43
43
  exports.getInfisicalConfig = getInfisicalConfig;
44
44
  exports.getInfisicalToken = getInfisicalToken;
45
45
  exports.getInfisicalSecrets = getInfisicalSecrets;
46
+ exports.loadCredsFileIntoEnv = loadCredsFileIntoEnv;
47
+ exports.readBuildboxPwFile = readBuildboxPwFile;
46
48
  exports.getCnInfisicalToken = getCnInfisicalToken;
47
49
  exports.getCnSecrets = getCnSecrets;
48
50
  exports.parseDatabaseUrl = parseDatabaseUrl;
51
+ exports.classifyTunnelTarget = classifyTunnelTarget;
49
52
  exports.ensureTunnel = ensureTunnel;
50
53
  exports.queryDB = queryDB;
51
54
  exports.connectCnDB = connectCnDB;
@@ -137,15 +140,87 @@ function curlJson(args) {
137
140
  throw new Error(`cn Infisical: non-JSON response: ${String(out).slice(0, 200)}`);
138
141
  }
139
142
  }
143
+ /** 明文密码文件权限比 600 宽(组/其他用户可读)时往 stderr 警告一行。Windows 无此语义,跳过。 */
144
+ function warnIfLoosePerms(file) {
145
+ if (process.platform === 'win32')
146
+ return;
147
+ try {
148
+ const mode = fs.statSync(file).mode & 0o777;
149
+ if (mode & 0o077)
150
+ console.error(`⚠️ ${file} 权限 ${mode.toString(8).padStart(3, '0')}(同机其他用户可读)——建议 chmod 600 ${file}`);
151
+ }
152
+ catch { /* stat 失败不阻塞主流程 */ }
153
+ }
154
+ /**
155
+ * 若 creds 文件存在,把其中 `export KEY=val` / `KEY=val` 行注入 process.env(已存在的键不覆盖)。
156
+ * 免去每次手动 `source ~/.infisical_cn_creds`;文件不存在则静默跳过,回退到调用方的 env 检查。
157
+ * ⚠️ 不是 shell:只认最朴素的每行一条 `KEY=值`。值以引号开头时取到配对引号为止(其后内容
158
+ * 忽略,行尾注释因此无害);引号未闭合的行警告并跳过(真 shell 会语法错,静默注入 `"abc`
159
+ * 这类错值只会换来下游 401、更难查);无引号值取到行尾原样保留(别写行尾注释,`#` 会进值)。
160
+ * 导出仅供测试。
161
+ */
162
+ const loadedCredsFiles = new Set(); // 同一文件只加载/警告一次(getCnInfisicalToken 与 setupCnSSHTunnel 都会调)
163
+ function loadCredsFileIntoEnv(file) {
164
+ if (loadedCredsFiles.has(file))
165
+ return;
166
+ loadedCredsFiles.add(file);
167
+ let content;
168
+ try {
169
+ content = fs.readFileSync(file, 'utf-8');
170
+ }
171
+ catch {
172
+ return;
173
+ } // 文件不存在/不可读 = 无操作,保持原有「读 env」行为
174
+ warnIfLoosePerms(file);
175
+ const lines = content.split(/\r?\n/);
176
+ for (let i = 0; i < lines.length; i++) {
177
+ const m = lines[i].match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
178
+ if (!m)
179
+ continue;
180
+ const key = m[1];
181
+ if (process.env[key] !== undefined)
182
+ continue; // 已有 env 优先,不覆盖
183
+ let val = m[2].trim();
184
+ const q = val[0];
185
+ if (q === '"' || q === "'") {
186
+ const close = val.indexOf(q, 1);
187
+ if (close === -1) {
188
+ console.error(`⚠️ ${file} 第 ${i + 1} 行(${key}=)引号未闭合,已跳过该行`);
189
+ continue;
190
+ }
191
+ val = val.slice(1, close); // 配对引号截取,忽略其后(如行尾注释)
192
+ }
193
+ process.env[key] = val;
194
+ }
195
+ }
196
+ /**
197
+ * 读 buildbox 密码文件(纯密码一行;默认 ~/.buildbox_pw,OPTIMA_CN_BUILDBOX_PW_FILE 可覆盖)。
198
+ * 文件不存在/为空 → undefined,回退到调用方报错。导出仅供测试。
199
+ */
200
+ function readBuildboxPwFile() {
201
+ const file = process.env.OPTIMA_CN_BUILDBOX_PW_FILE || `${os.homedir()}/.buildbox_pw`;
202
+ let pw;
203
+ try {
204
+ pw = fs.readFileSync(file, 'utf-8').trim();
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ warnIfLoosePerms(file); // 读到文件就查权限,空文件也警告
210
+ if (!pw)
211
+ return undefined;
212
+ return pw;
213
+ }
140
214
  /**
141
215
  * 认证 cn Infisical(admin email/password → org-scoped token)。
142
216
  * ⚠️ 字段名坑:login 返回 `accessToken`(不是 token),select-organization 才返回 `token`。
143
217
  */
144
218
  function getCnInfisicalToken() {
219
+ loadCredsFileIntoEnv(process.env.INFISICAL_CN_CREDS_FILE || `${os.homedir()}/.infisical_cn_creds`);
145
220
  const email = process.env.INFISICAL_CN_EMAIL;
146
221
  const password = process.env.INFISICAL_CN_PASSWORD;
147
222
  if (!email || !password) {
148
- throw new Error('cn Infisical 需要 INFISICAL_CN_EMAIL + INFISICAL_CN_PASSWORD 环境变量(admin user,1P "Infisical cn-prod admin (secrets-cn.optima.chat)")。见 optima-dev-skills#21。');
223
+ throw new Error('cn Infisical 需要 INFISICAL_CN_EMAIL + INFISICAL_CN_PASSWORD。① 1Password "Infisical cn-prod admin (secrets-cn.optima.chat)" 取值 export(不落盘);或 ② 本工具自动读取本地文件 ~/.infisical_cn_creds(每行 `export KEY=val`,INFISICAL_CN_CREDS_FILE 可覆盖)。见 optima-dev-skills#21。');
149
224
  }
150
225
  const login = curlJson([
151
226
  '-X', 'POST', `${CN_INFISICAL_URL}/api/v3/auth/login`,
@@ -276,6 +351,79 @@ function isTunnelProcessOnPort(localPort) {
276
351
  }
277
352
  });
278
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
+ }
279
427
  function killOrphanTunnel(localPort) {
280
428
  // 只杀我们的隧道进程 —— 端口上若是别人(docker-proxy/postgres),kill -9 是破坏性的。
281
429
  for (const pid of portPids(localPort)) {
@@ -357,9 +505,11 @@ function setupSSMTunnel(dbHost, localPort) {
357
505
  * 密码经 SSHPASS 环境变量传给 `sshpass -e`:不进命令行(ps 不可见)、不经 shell 引号展开。
358
506
  */
359
507
  function setupCnSSHTunnel(dbHost, localPort) {
360
- const pw = process.env.OPTIMA_CN_BUILDBOX_PASSWORD;
508
+ // creds 文件里也可能放 OPTIMA_CN_BUILDBOX_PASSWORD——自己加载一次(幂等),不依赖 getCnInfisicalToken 恰好先跑
509
+ loadCredsFileIntoEnv(process.env.INFISICAL_CN_CREDS_FILE || `${os.homedir()}/.infisical_cn_creds`);
510
+ const pw = process.env.OPTIMA_CN_BUILDBOX_PASSWORD || readBuildboxPwFile();
361
511
  if (!pw)
362
- throw new Error('cn DB 隧道需要 OPTIMA_CN_BUILDBOX_PASSWORD 环境变量(buildbox root 密码,1P "Aliyun cn-prod buildbox ECS (root)")。见 optima-dev-skills#21。');
512
+ throw new Error('cn DB 隧道需要 OPTIMA_CN_BUILDBOX_PASSWORD。① 1Password "Aliyun cn-prod buildbox ECS (root)" 取值 export;或 ② 本工具自动读取本地文件 ~/.buildbox_pw(纯密码一行,OPTIMA_CN_BUILDBOX_PW_FILE 可覆盖路径)。见 optima-dev-skills#21。');
363
513
  try {
364
514
  (0, child_process_1.execSync)('command -v sshpass', { stdio: 'ignore' });
365
515
  }
@@ -406,14 +556,23 @@ function writeTunnelRegistry(reg) {
406
556
  fs.mkdirSync(`${os.homedir()}/.cache/optima-dev-skills`, { recursive: true });
407
557
  fs.writeFileSync(TUNNEL_REGISTRY, JSON.stringify(reg, null, 2));
408
558
  }
409
- /** First free local port, preferring the registry's previous assignment. */
410
- function pickTunnelPort(preferred) {
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()) {
411
568
  const candidates = preferred ? [preferred] : [];
412
569
  for (let p = TUNNEL_PORT_SCAN_BASE; p < TUNNEL_PORT_SCAN_BASE + TUNNEL_PORT_SCAN_LIMIT; p++) {
413
570
  if (p !== preferred)
414
571
  candidates.push(p);
415
572
  }
416
573
  for (const p of candidates) {
574
+ if (reserved.has(p))
575
+ continue;
417
576
  if (portPids(p).length === 0 && !isPortResponding(p))
418
577
  return p;
419
578
  }
@@ -432,15 +591,31 @@ function ensureTunnel(dbHost, via = 'aws') {
432
591
  throw new Error('ensureTunnel: dbHost contains unexpected characters (refusing to build tunnel command)');
433
592
  const reg = readTunnelRegistry();
434
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);
435
599
  if (known !== undefined && isTunnelProcessOnPort(known)) {
436
- if (isTunnelHealthy(known))
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)) {
437
607
  return known; // warm reuse — no cold start
438
- console.log(`! Tunnel on port ${known} not responding (zombie), replacing...`);
439
- killOrphanTunnel(known);
608
+ }
609
+ else {
610
+ console.log(`! Tunnel on port ${known} not responding (zombie), replacing...`);
611
+ killOrphanTunnel(known);
612
+ }
440
613
  }
441
614
  // Foreign process on the recorded port (e.g. a local Docker PG) → pick a
442
615
  // different port instead of talking to whatever squats there.
443
- const port = pickTunnelPort(known);
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);
444
619
  if (via === 'cn-buildbox') {
445
620
  setupCnSSHTunnel(dbHost, port);
446
621
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optima-chat/dev-skills",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "Claude Code Skills for Optima development team - cross-environment collaboration tools",
5
5
  "main": "index.js",
6
6
  "bin": {