@optima-chat/dev-skills 0.7.37 → 0.7.40

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.
@@ -1,16 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.validateEnv = validateEnv;
4
+ exports.validateEnvCnProd = validateEnvCnProd;
4
5
  exports.getServiceToken = getServiceToken;
5
6
  exports.callBilling = callBilling;
6
7
  exports.callSkills = callSkills;
8
+ exports.resolveUserIdByEmail = resolveUserIdByEmail;
7
9
  const child_process_1 = require("child_process");
8
10
  const infisical_secrets_1 = require("./infisical-secrets");
9
11
  const db_utils_1 = require("./db-utils");
10
12
  const USER_AUTH_URLS = {
11
13
  stage: 'https://auth.stage.optima.onl',
12
14
  prod: 'https://auth.optima.onl',
15
+ 'cn-prod': 'https://auth-cn.optima.chat',
13
16
  };
17
+ // cn-prod URLs are hardcoded: cn Infisical (secrets-cn.optima.chat) is a
18
+ // separate instance dev-skills has no machine identity for, and these domains
19
+ // are stable. AWS envs keep reading /shared-secrets/domain-urls.
20
+ const CN_PROD_BILLING_URL = 'https://billing-cn.optima.chat';
14
21
  /**
15
22
  * Validate the --env flag value at command entry, before any I/O.
16
23
  *
@@ -27,12 +34,38 @@ function validateEnv(env) {
27
34
  }
28
35
  return env;
29
36
  }
37
+ /**
38
+ * Variant for commands that also support cn-prod — currently grant-balance /
39
+ * grant-subscription, which reach billing + user-auth over HTTPS only.
40
+ * Other commands resolve users via the AWS RDS SSH tunnel, which does not
41
+ * exist for cn-prod (Aliyun VPC-internal RDS) — keep them on validateEnv so a
42
+ * cn-prod typo fails fast instead of dying inside the tunnel setup.
43
+ */
44
+ function validateEnvCnProd(env) {
45
+ if (env !== 'stage' && env !== 'prod' && env !== 'cn-prod') {
46
+ throw new Error(`--env must be "stage", "prod" or "cn-prod" (got: ${env})`);
47
+ }
48
+ return env;
49
+ }
30
50
  // T1 discovered: client_id differs per env (stage=dev-skills-ubd3qz6n,
31
51
  // prod=dev-skills-hinxa0rs). Both stored in Infisical alongside the
32
52
  // secret at /shared-secrets/oauth-clients/.
33
53
  const DEV_SKILLS_OAUTH_PATH = '/shared-secrets/oauth-clients';
34
54
  const DEV_SKILLS_CLIENT_ID_KEY = 'DEV_SKILLS_OAUTH_CLIENT_ID';
35
55
  const DEV_SKILLS_CLIENT_SECRET_KEY = 'DEV_SKILLS_OAUTH_CLIENT_SECRET';
56
+ // cn-prod: the client (dev-skills-ecee51qo) lives in cn user-auth, but its
57
+ // credentials are mirrored into AWS Infisical (prod environment, same path)
58
+ // under CN_PROD-prefixed keys so we reuse the existing Infisical access —
59
+ // zero new credential chain. Canonical copy lives in cn Infisical
60
+ // /shared-secrets/oauth-clients (DEV_SKILLS_OAUTH_CLIENT_ID/SECRET).
61
+ const DEV_SKILLS_CN_CLIENT_ID_KEY = 'DEV_SKILLS_CN_PROD_OAUTH_CLIENT_ID';
62
+ const DEV_SKILLS_CN_CLIENT_SECRET_KEY = 'DEV_SKILLS_CN_PROD_OAUTH_CLIENT_SECRET';
63
+ // cn-prod tokens must carry this scope: resolveUserIdByEmail calls cn
64
+ // user-auth POST /api/v1/internal/users/lookup, whose guard
65
+ // (verify_internal_service_token) requires it. user-auth issues
66
+ // request∩allowed_scopes and an unscoped request yields scope="" (verified
67
+ // against cn-prod 2026-06-12), so the request must name it explicitly.
68
+ const CN_PROD_TOKEN_SCOPE = 'internal:users:write';
36
69
  // ───── Cache (process-lifetime) ─────────────────────────────────────────────
37
70
  // One CLI invocation does at most a handful of HTTP calls. We mint the M2M
38
71
  // token once and reuse it. Cross-invocation re-mint is fine — JWT TTL is
@@ -47,6 +80,8 @@ const tokenCache = {};
47
80
  const billingUrlCache = {};
48
81
  const skillsUrlCache = {};
49
82
  function getBillingUrl(env) {
83
+ if (env === 'cn-prod')
84
+ return CN_PROD_BILLING_URL;
50
85
  if (billingUrlCache[env])
51
86
  return billingUrlCache[env];
52
87
  const url = (0, infisical_secrets_1.fetchInfisicalSecret)(env, '/shared-secrets/domain-urls', 'BILLING_URL');
@@ -66,12 +101,19 @@ function getServiceToken(env) {
66
101
  const cfg = (0, db_utils_1.getInfisicalConfig)();
67
102
  const tok = (0, db_utils_1.getInfisicalToken)(cfg);
68
103
  // Fetch BOTH client_id and client_secret from Infisical — they differ per env.
69
- const clientId = (0, infisical_secrets_1.fetchInfisicalSecret)(env, DEV_SKILLS_OAUTH_PATH, DEV_SKILLS_CLIENT_ID_KEY, cfg, tok);
70
- const clientSecret = (0, infisical_secrets_1.fetchInfisicalSecret)(env, DEV_SKILLS_OAUTH_PATH, DEV_SKILLS_CLIENT_SECRET_KEY, cfg, tok);
104
+ // cn-prod credentials are mirrored in the AWS Infisical *prod* environment
105
+ // (fetchInfisicalSecret has no cn-prod env slug), under CN-specific keys.
106
+ const isCn = env === 'cn-prod';
107
+ const infisicalEnv = isCn ? 'prod' : env;
108
+ const idKey = isCn ? DEV_SKILLS_CN_CLIENT_ID_KEY : DEV_SKILLS_CLIENT_ID_KEY;
109
+ const secretKey = isCn ? DEV_SKILLS_CN_CLIENT_SECRET_KEY : DEV_SKILLS_CLIENT_SECRET_KEY;
110
+ const clientId = (0, infisical_secrets_1.fetchInfisicalSecret)(infisicalEnv, DEV_SKILLS_OAUTH_PATH, idKey, cfg, tok);
111
+ const clientSecret = (0, infisical_secrets_1.fetchInfisicalSecret)(infisicalEnv, DEV_SKILLS_OAUTH_PATH, secretKey, cfg, tok);
71
112
  const authUrl = USER_AUTH_URLS[env];
72
113
  if (!authUrl)
73
114
  throw new Error(`Unknown env: ${env}`);
74
- const body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`;
115
+ const scopeParam = isCn ? `&scope=${encodeURIComponent(CN_PROD_TOKEN_SCOPE)}` : '';
116
+ const body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}${scopeParam}`;
75
117
  const response = (0, child_process_1.execSync)(`curl -s -X POST '${authUrl}/api/v1/oauth/token' -H 'Content-Type: application/x-www-form-urlencoded' -d '${body}'`, { encoding: 'utf-8' });
76
118
  let parsed;
77
119
  try {
@@ -151,3 +193,40 @@ async function callBilling(env, method, path, body) {
151
193
  async function callSkills(env, method, path, body) {
152
194
  return callService(getSkillsUrl(env), env, method, path, body);
153
195
  }
196
+ /**
197
+ * Resolve a user's id by email via user-auth's internal lookup endpoint
198
+ * (POST /api/v1/internal/users/lookup). cn-prod only: AWS envs resolve via
199
+ * the RDS SSH tunnel (db-utils resolveUserId) and their dev-skills clients
200
+ * don't carry the internal:users:write scope this endpoint requires.
201
+ */
202
+ async function resolveUserIdByEmail(env, email) {
203
+ console.log(`Looking up user by email: ${email}`);
204
+ const token = getServiceToken(env);
205
+ const authUrl = USER_AUTH_URLS[env];
206
+ if (!authUrl)
207
+ throw new Error(`Unknown env: ${env}`);
208
+ const res = await fetch(`${authUrl}/api/v1/internal/users/lookup`, {
209
+ method: 'POST',
210
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({ email }),
212
+ });
213
+ const text = await res.text();
214
+ if (res.status === 404) {
215
+ throw new Error(`User not found (${env}): ${email}`);
216
+ }
217
+ if (!res.ok) {
218
+ throw new Error(formatServiceError(res.status, res.statusText, text));
219
+ }
220
+ let parsed;
221
+ try {
222
+ parsed = JSON.parse(text);
223
+ }
224
+ catch {
225
+ throw new Error(`user-auth lookup returned non-JSON 2xx body: ${text.slice(0, 200)}`);
226
+ }
227
+ if (!parsed.user_id) {
228
+ throw new Error(`user-auth lookup response missing user_id: ${text.slice(0, 200)}`);
229
+ }
230
+ console.log(`✓ Found user: ${parsed.user_id}`);
231
+ return parsed.user_id;
232
+ }
@@ -34,25 +34,48 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.RDS_HOSTS = void 0;
37
+ exports.isCnEnv = isCnEnv;
37
38
  exports.escapeSQL = escapeSQL;
38
39
  exports.getGitHubVariable = getGitHubVariable;
39
40
  exports.getInfisicalConfig = getInfisicalConfig;
40
41
  exports.getInfisicalToken = getInfisicalToken;
41
42
  exports.getInfisicalSecrets = getInfisicalSecrets;
43
+ exports.getCnInfisicalToken = getCnInfisicalToken;
44
+ exports.getCnSecrets = getCnSecrets;
42
45
  exports.parseDatabaseUrl = parseDatabaseUrl;
43
- exports.setupSSHTunnel = setupSSHTunnel;
46
+ exports.ensureTunnel = ensureTunnel;
44
47
  exports.queryDB = queryDB;
48
+ exports.connectCnDB = connectCnDB;
49
+ exports.connectCnDBFromUrl = connectCnDBFromUrl;
45
50
  exports.connectAuthDB = connectAuthDB;
46
51
  exports.connectBillingDB = connectBillingDB;
47
52
  exports.resolveUserId = resolveUserId;
48
53
  const child_process_1 = require("child_process");
49
54
  const fs = __importStar(require("fs"));
55
+ const os = __importStar(require("os"));
50
56
  // ─── Constants ──────────────────────────────────────────────────────────────
51
57
  exports.RDS_HOSTS = {
52
58
  stage: 'optima-stage-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
53
59
  prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
54
60
  };
61
+ // Shared bastion (optima-bi-data EC2, shared-services stack). Reached via SSM —
62
+ // no public port 22 dependency, so it is immune to the MaxStartups throttling that
63
+ // the open-to-world sshd suffers under internet scan load. SSH path kept as fallback.
64
+ const AWS_REGION = 'ap-southeast-1';
65
+ const BASTION_INSTANCE_ID = 'i-03286fb0a9ce7e6b1';
55
66
  const EC2_HOST = '3.0.210.113';
67
+ // ─── cn-prod (阿里云) 常量 ────────────────────────────────────────────────────
68
+ // cn-prod 跑在阿里云 SAE,与海外 AWS 完全独立:独立 Infisical (secrets-cn.optima.chat)
69
+ // + 内网 RDS(无公网端点)经 buildbox ECS 跳板 SSH 隧道访问。设计见 optima-dev-skills#21。
70
+ const CN_INFISICAL_URL = process.env.INFISICAL_CN_URL || 'https://secrets-cn.optima.chat';
71
+ const CN_INFISICAL_ORG = process.env.INFISICAL_CN_ORG || 'f44012fa-0659-4f7e-b0ac-ed01244efc8a';
72
+ const CN_INFISICAL_PROJECT = process.env.INFISICAL_CN_PROJECT || '4deef229-11be-40a5-8f56-b61f0bce7240';
73
+ const CN_RDS_HOST = 'pgm-2zexwx9eso9e4yla.pg.rds.aliyuncs.com';
74
+ const CN_BUILDBOX_HOST = process.env.OPTIMA_CN_BUILDBOX_HOST || '47.94.105.163';
75
+ /** env 是否指向 cn-prod(阿里云)。接受 `cn` / `cn-prod`。 */
76
+ function isCnEnv(env) {
77
+ return env === 'cn' || env === 'cn-prod';
78
+ }
56
79
  // ─── SQL escaping ───────────────────────────────────────────────────────────
57
80
  /** Escape a string value for safe inclusion in SQL single-quoted literals. */
58
81
  function escapeSQL(value) {
@@ -60,7 +83,8 @@ function escapeSQL(value) {
60
83
  }
61
84
  // ─── GitHub Variables ───────────────────────────────────────────────────────
62
85
  function getGitHubVariable(name) {
63
- return (0, child_process_1.execSync)(`gh variable get ${name} -R Optima-Chat/optima-dev-skills`, { encoding: 'utf-8' }).trim();
86
+ // `gh variable` has no `get` subcommand (only list/set/delete); read the value via the REST variables endpoint.
87
+ return (0, child_process_1.execSync)(`gh api repos/Optima-Chat/optima-dev-skills/actions/variables/${name} --jq .value`, { encoding: 'utf-8' }).trim();
64
88
  }
65
89
  // ─── Infisical ──────────────────────────────────────────────────────────────
66
90
  function getInfisicalConfig() {
@@ -84,12 +108,102 @@ function getInfisicalSecrets(config, token, environment, secretPath) {
84
108
  }
85
109
  return secrets;
86
110
  }
111
+ // ─── cn Infisical(独立实例,admin email/password 认证)──────────────────────
112
+ /** curl → JSON,用 execFileSync 传参(避免 shell 引号坑,跨平台安全)。 */
113
+ function curlJson(args) {
114
+ const out = (0, child_process_1.execFileSync)('curl', ['-s', ...args], { encoding: 'utf-8' });
115
+ try {
116
+ return JSON.parse(out || '{}');
117
+ }
118
+ catch {
119
+ throw new Error(`cn Infisical: non-JSON response: ${String(out).slice(0, 200)}`);
120
+ }
121
+ }
122
+ /**
123
+ * 认证 cn Infisical(admin email/password → org-scoped token)。
124
+ * ⚠️ 字段名坑:login 返回 `accessToken`(不是 token),select-organization 才返回 `token`。
125
+ */
126
+ function getCnInfisicalToken() {
127
+ const email = process.env.INFISICAL_CN_EMAIL;
128
+ const password = process.env.INFISICAL_CN_PASSWORD;
129
+ if (!email || !password) {
130
+ 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。');
131
+ }
132
+ const login = curlJson([
133
+ '-X', 'POST', `${CN_INFISICAL_URL}/api/v3/auth/login`,
134
+ '-H', 'Content-Type: application/json',
135
+ '-d', JSON.stringify({ email, password }),
136
+ ]);
137
+ if (!login.accessToken)
138
+ throw new Error(`cn Infisical login 失败: ${JSON.stringify(login).slice(0, 200)}`);
139
+ const org = curlJson([
140
+ '-X', 'POST', `${CN_INFISICAL_URL}/api/v3/auth/select-organization`,
141
+ '-H', 'Content-Type: application/json',
142
+ '-H', `Authorization: Bearer ${login.accessToken}`,
143
+ '-d', JSON.stringify({ organizationId: CN_INFISICAL_ORG }),
144
+ ]);
145
+ if (!org.token)
146
+ throw new Error(`cn Infisical select-organization 失败: ${JSON.stringify(org).slice(0, 200)}`);
147
+ return org.token;
148
+ }
149
+ /** 读 cn Infisical 某 folder(prod env)→ key→value map。expand=true 让服务端解析 `${...}` 引用。 */
150
+ function getCnSecrets(token, secretPath, expand = false) {
151
+ const data = curlJson([
152
+ `${CN_INFISICAL_URL}/api/v3/secrets/raw?workspaceId=${CN_INFISICAL_PROJECT}&environment=prod&secretPath=${encodeURIComponent(secretPath)}&expandSecretReferences=${expand}`,
153
+ '-H', `Authorization: Bearer ${token}`,
154
+ ]);
155
+ const secrets = {};
156
+ for (const s of data.secrets || [])
157
+ secrets[s.secretKey] = s.secretValue;
158
+ return secrets;
159
+ }
87
160
  // ─── Database URL parsing ───────────────────────────────────────────────────
161
+ /** decodeURIComponent,但密码未做 URL 编码、含裸 % 时不炸:原样返回。 */
162
+ function safeDecode(s) {
163
+ try {
164
+ return decodeURIComponent(s);
165
+ }
166
+ catch {
167
+ return s;
168
+ }
169
+ }
170
+ /**
171
+ * 解析 DATABASE_URL。不用正则一把抓:userinfo 从 authority 的**最后一个 @** 右切,
172
+ * 密码含 @ * + $ 等特殊字符也不会把密码段误并进 host(曾导致隧道目标变成
173
+ * `密码片段@pgm-xxx` 且把密码打进 console)。容忍驱动后缀(postgresql+asyncpg://)。
174
+ * ⚠️ 所有错误信息不回显 URL 本身 —— DATABASE_URL 含凭证,不能进日志。
175
+ */
88
176
  function parseDatabaseUrl(url) {
89
- const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
90
- if (!match)
91
- throw new Error('Failed to parse DATABASE_URL (format: postgresql://user:pass@host:port/db)');
92
- return { user: decodeURIComponent(match[1]), password: decodeURIComponent(match[2]), host: match[3], port: parseInt(match[4], 10), database: match[5] };
177
+ const fail = (why) => {
178
+ throw new Error(`Failed to parse DATABASE_URL (${why}; format: postgresql://user:pass@host:port/db)`);
179
+ };
180
+ const scheme = url.match(/^postgres(?:ql)?(?:\+\w+)?:\/\//);
181
+ if (!scheme)
182
+ fail('unsupported scheme');
183
+ const rest = url.slice(scheme[0].length);
184
+ const slash = rest.indexOf('/');
185
+ const authority = slash === -1 ? rest : rest.slice(0, slash);
186
+ const at = authority.lastIndexOf('@');
187
+ if (at === -1)
188
+ fail('missing user:pass@');
189
+ const userinfo = authority.slice(0, at);
190
+ const colon = userinfo.indexOf(':');
191
+ if (colon === -1)
192
+ fail('missing password in userinfo');
193
+ // host 只允许域名字符 —— 解析错位时密码片段会落到这里,fail-closed 别让它流向隧道命令/日志
194
+ const hostport = authority.slice(at + 1).match(/^([A-Za-z0-9.-]+)(?::(\d+))?$/);
195
+ if (!hostport)
196
+ fail('invalid host:port');
197
+ const database = slash === -1 ? '' : rest.slice(slash + 1).split('?')[0];
198
+ if (!database)
199
+ fail('missing database name');
200
+ return {
201
+ user: safeDecode(userinfo.slice(0, colon)),
202
+ password: safeDecode(userinfo.slice(colon + 1)),
203
+ host: hostport[1],
204
+ port: hostport[2] ? parseInt(hostport[2], 10) : 5432,
205
+ database,
206
+ };
93
207
  }
94
208
  // ─── SSH tunnel ─────────────────────────────────────────────────────────────
95
209
  // 端口被占不等于 tunnel 还能转发:AWS bastion idle 断连后,本地 ssh 进程还活着、
@@ -104,27 +218,55 @@ function isTunnelHealthy(localPort) {
104
218
  return false;
105
219
  }
106
220
  }
107
- function killOrphanTunnel(localPort) {
221
+ function portPids(localPort) {
108
222
  try {
109
- const pids = (0, child_process_1.execSync)(`lsof -ti:${localPort}`, { encoding: 'utf-8' }).trim();
110
- if (pids)
111
- (0, child_process_1.execSync)(`kill -9 ${pids.split(/\s+/).join(' ')}`, { stdio: 'ignore' });
223
+ return (0, child_process_1.execSync)(`lsof -ti:${localPort}`, { encoding: 'utf-8' }).trim().split(/\s+/).filter(Boolean);
224
+ }
225
+ catch {
226
+ return [];
112
227
  }
113
- catch { /* nothing to kill */ }
114
228
  }
115
- function setupSSHTunnel(dbHost, localPort) {
116
- let portInUse = false;
229
+ // lsof 只能看到「有进程 bind」的端口。Docker 发布端口走 iptables DNAT 时主机上
230
+ // 没有进程绑定(lsof 空),但流量照样被内核截给容器 —— 在这种端口上建隧道,
231
+ // psql 连的还是容器里的库。所以空闲判定必须用客户端视角:真去 connect,有人
232
+ // 应答(无论进程还是 DNAT)就算被占。
233
+ function isPortResponding(localPort) {
117
234
  try {
118
- (0, child_process_1.execSync)(`lsof -ti:${localPort}`, { stdio: 'ignore' });
119
- portInUse = true;
235
+ // 单条命令:connect 失败 bash exit 非 0(再跟一条命令会把退出码盖掉);
236
+ // fd 随 bash 进程退出自动关闭。
237
+ (0, child_process_1.execSync)(`bash -c 'exec 3<>/dev/tcp/127.0.0.1/${localPort}'`, { stdio: 'ignore', timeout: 2000 });
238
+ return true;
120
239
  }
121
- catch { /* free */ }
122
- if (portInUse) {
123
- if (isTunnelHealthy(localPort))
124
- return;
125
- console.log(`! SSH tunnel on port ${localPort} not responding (zombie), replacing...`);
126
- killOrphanTunnel(localPort);
240
+ catch {
241
+ return false;
127
242
  }
243
+ }
244
+ // 端口被占 ≠ 是我们的隧道:本机 Docker PG 也会监听这些端口,且对 pg_isready
245
+ // 完全健康 —— 曾把 prod 凭证发给本机 15433 上的 Docker PG,报「密码错误」。
246
+ // 判别器 = 占用进程的 comm:ssh(legacy 隧道)或 session-manager-plugin(SSM)。
247
+ function isTunnelProcessOnPort(localPort) {
248
+ return portPids(localPort).some(pid => {
249
+ try {
250
+ const comm = (0, child_process_1.execSync)(`ps -o comm= -p ${pid}`, { encoding: 'utf-8' }).trim();
251
+ return comm === 'ssh' || comm.includes('session-manager');
252
+ }
253
+ catch {
254
+ return false;
255
+ }
256
+ });
257
+ }
258
+ function killOrphanTunnel(localPort) {
259
+ // 只杀我们的隧道进程 —— 端口上若是别人(docker-proxy/postgres),kill -9 是破坏性的。
260
+ for (const pid of portPids(localPort)) {
261
+ try {
262
+ const comm = (0, child_process_1.execSync)(`ps -o comm= -p ${pid}`, { encoding: 'utf-8' }).trim();
263
+ if (comm === 'ssh' || comm.includes('session-manager'))
264
+ (0, child_process_1.execSync)(`kill -9 ${pid}`, { stdio: 'ignore' });
265
+ }
266
+ catch { /* already gone */ }
267
+ }
268
+ }
269
+ function setupSSHTunnel(dbHost, localPort) {
128
270
  const sshKeyPath = `${process.env.HOME}/.ssh/optima-ec2-key`;
129
271
  if (!fs.existsSync(sshKeyPath))
130
272
  throw new Error(`SSH key not found: ${sshKeyPath}. Please obtain optima-ec2-key from xbfool.`);
@@ -137,6 +279,160 @@ function setupSSHTunnel(dbHost, localPort) {
137
279
  `-o ExitOnForwardFailure=yes -o ConnectTimeout=10 ` +
138
280
  `-L ${localPort}:${dbHost}:5432 ec2-user@${EC2_HOST}`, { stdio: 'inherit' });
139
281
  }
282
+ /** Block the current (sync) call for `ms` without spawning a subprocess. */
283
+ function sleepSync(ms) {
284
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
285
+ }
286
+ function assertSSMPrereqs() {
287
+ for (const [bin, hint] of [
288
+ ['session-manager-plugin', 'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html'],
289
+ ['aws', 'AWS CLI v2: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html'],
290
+ ]) {
291
+ try {
292
+ (0, child_process_1.execSync)(`command -v ${bin}`, { stdio: 'ignore' });
293
+ }
294
+ catch {
295
+ throw new Error(`${bin} not found — required for the SSM DB tunnel. Install: ${hint}\n(or set OPTIMA_DB_TUNNEL=ssh to fall back to the legacy SSH tunnel)`);
296
+ }
297
+ }
298
+ }
299
+ /**
300
+ * Open a local→RDS tunnel through the shared bastion using SSM port forwarding.
301
+ * No public SSH port is touched. A healthy existing tunnel on the port is reused
302
+ * (the ~2s SSM cold start is paid once, then every query is just a round-trip).
303
+ */
304
+ function setupSSMTunnel(dbHost, localPort) {
305
+ assertSSMPrereqs();
306
+ console.log(`Creating SSM tunnel: localhost:${localPort} -> ${BASTION_INSTANCE_ID} -> ${dbHost}:5432`);
307
+ const logFile = `${os.tmpdir()}/optima-ssm-tunnel-${localPort}.log`;
308
+ const out = fs.openSync(logFile, 'w');
309
+ const child = (0, child_process_1.spawn)('aws', [
310
+ 'ssm', 'start-session',
311
+ '--region', AWS_REGION,
312
+ '--target', BASTION_INSTANCE_ID,
313
+ '--document-name', 'AWS-StartPortForwardingSessionToRemoteHost',
314
+ '--parameters', `host=${dbHost},portNumber=5432,localPortNumber=${localPort}`,
315
+ ], { detached: true, stdio: ['ignore', out, out] });
316
+ child.unref();
317
+ // Wait until the remote leg actually forwards (pg_isready probes RDS through the
318
+ // tunnel). Local port binds instantly; the remote hop needs ~1-2s to come up.
319
+ const deadlineMs = Date.now() + 20000;
320
+ while (Date.now() < deadlineMs) {
321
+ if (isTunnelHealthy(localPort)) {
322
+ console.log(`✓ SSM tunnel ready on port ${localPort}`);
323
+ return;
324
+ }
325
+ sleepSync(500);
326
+ }
327
+ let tail = '(no log)';
328
+ try {
329
+ tail = fs.readFileSync(logFile, 'utf-8').split('\n').slice(-8).join('\n');
330
+ }
331
+ catch { /* ignore */ }
332
+ throw new Error(`SSM tunnel on port ${localPort} did not become ready within 20s.\n--- ${logFile} ---\n${tail}`);
333
+ }
334
+ /**
335
+ * 隧道 localhost:localPort → cn 内网 RDS,经 buildbox ECS 跳板(sshpass SSH)。
336
+ * 密码经 SSHPASS 环境变量传给 `sshpass -e`:不进命令行(ps 不可见)、不经 shell 引号展开。
337
+ */
338
+ function setupCnSSHTunnel(dbHost, localPort) {
339
+ const pw = process.env.OPTIMA_CN_BUILDBOX_PASSWORD;
340
+ if (!pw)
341
+ throw new Error('cn DB 隧道需要 OPTIMA_CN_BUILDBOX_PASSWORD 环境变量(buildbox root 密码,1P "Aliyun cn-prod buildbox ECS (root)")。见 optima-dev-skills#21。');
342
+ try {
343
+ (0, child_process_1.execSync)('command -v sshpass', { stdio: 'ignore' });
344
+ }
345
+ catch {
346
+ throw new Error('sshpass not found — cn buildbox 隧道需要(apt install sshpass / brew install esolitos/ipa/sshpass)。Windows 用 WSL。');
347
+ }
348
+ console.log(`Creating cn tunnel: localhost:${localPort} -> buildbox ${CN_BUILDBOX_HOST} -> ${dbHost}:5432`);
349
+ (0, child_process_1.execSync)(`sshpass -e ssh -f -N -o StrictHostKeyChecking=no ` +
350
+ `-o ServerAliveInterval=30 -o ServerAliveCountMax=3 ` +
351
+ `-o ExitOnForwardFailure=yes -o ConnectTimeout=12 ` +
352
+ `-L ${localPort}:${dbHost}:5432 root@${CN_BUILDBOX_HOST}`, { stdio: 'inherit', env: { ...process.env, SSHPASS: pw } });
353
+ const deadlineMs = Date.now() + 20000;
354
+ while (Date.now() < deadlineMs) {
355
+ if (isTunnelHealthy(localPort)) {
356
+ console.log(`✓ cn tunnel ready on port ${localPort}`);
357
+ return;
358
+ }
359
+ sleepSync(500);
360
+ }
361
+ throw new Error(`cn tunnel on port ${localPort} did not become ready within 20s`);
362
+ }
363
+ // ─── Tunnel port registry ───────────────────────────────────────────────────
364
+ // 端口动态分配:固定端口(旧 15432-15435)会撞本机服务(Docker PG 占 15433 →
365
+ // prod 凭证发给错误的库报「密码错误」)。每个 RDS host 一条隧道,实际端口记在
366
+ // 注册文件里,复用前验证「端口上确实是我们的隧道进程且健康」。
367
+ const TUNNEL_REGISTRY = `${os.homedir()}/.cache/optima-dev-skills/tunnel-ports.json`;
368
+ // 基址刻意避开 1543x/543x:本机 Docker PG 常映射在那一带,DNAT 端口即使我们的
369
+ // 隧道进程 bind 成功,OUTPUT 链的 DNAT 仍会把 127.0.0.1 流量截给容器(实测),
370
+ // 复用判定无法从外部区分 —— 离它远点是最便宜的防线。
371
+ const TUNNEL_PORT_SCAN_BASE = 25432;
372
+ const TUNNEL_PORT_SCAN_LIMIT = 100;
373
+ function readTunnelRegistry() {
374
+ try {
375
+ return JSON.parse(fs.readFileSync(TUNNEL_REGISTRY, 'utf-8'));
376
+ }
377
+ catch {
378
+ return {};
379
+ }
380
+ }
381
+ function writeTunnelRegistry(reg) {
382
+ // 注册文件非并发安全(全量覆盖、无锁):并发 CLI 进程可能互相覆盖记录或抢同
383
+ // 一空闲端口。后果 fail-closed(多建一条隧道 / 第二个 bind 失败报错重试),
384
+ // 对单人运维 CLI 可接受,不为此引入锁。
385
+ fs.mkdirSync(`${os.homedir()}/.cache/optima-dev-skills`, { recursive: true });
386
+ fs.writeFileSync(TUNNEL_REGISTRY, JSON.stringify(reg, null, 2));
387
+ }
388
+ /** First free local port, preferring the registry's previous assignment. */
389
+ function pickTunnelPort(preferred) {
390
+ const candidates = preferred ? [preferred] : [];
391
+ for (let p = TUNNEL_PORT_SCAN_BASE; p < TUNNEL_PORT_SCAN_BASE + TUNNEL_PORT_SCAN_LIMIT; p++) {
392
+ if (p !== preferred)
393
+ candidates.push(p);
394
+ }
395
+ for (const p of candidates) {
396
+ if (portPids(p).length === 0 && !isPortResponding(p))
397
+ return p;
398
+ }
399
+ 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`);
400
+ }
401
+ /**
402
+ * Ensure a local→RDS tunnel to `dbHost` exists and return its local port.
403
+ * One tunnel per RDS host (auth/billing on the same instance share it) —
404
+ * registry 按 dbHost 记端口,换库自然换隧道,不存在「端口被占就当同一条」的错配。
405
+ * AWS defaults to SSM (no public port 22; set OPTIMA_DB_TUNNEL=ssh for legacy SSH);
406
+ * `via: 'cn-buildbox'` 走阿里云 buildbox ECS 跳板(cn 内网 RDS 无公网端点)。
407
+ */
408
+ function ensureTunnel(dbHost, via = 'aws') {
409
+ // dbHost 可能来自解析的 DATABASE_URL,且会进 ssh 命令行/日志 —— 只放行域名字符。
410
+ if (!/^[A-Za-z0-9.-]+$/.test(dbHost))
411
+ throw new Error('ensureTunnel: dbHost contains unexpected characters (refusing to build tunnel command)');
412
+ const reg = readTunnelRegistry();
413
+ const known = reg[dbHost];
414
+ if (known !== undefined && isTunnelProcessOnPort(known)) {
415
+ if (isTunnelHealthy(known))
416
+ return known; // warm reuse — no cold start
417
+ console.log(`! Tunnel on port ${known} not responding (zombie), replacing...`);
418
+ killOrphanTunnel(known);
419
+ }
420
+ // Foreign process on the recorded port (e.g. a local Docker PG) → pick a
421
+ // different port instead of talking to whatever squats there.
422
+ const port = pickTunnelPort(known);
423
+ if (via === 'cn-buildbox') {
424
+ setupCnSSHTunnel(dbHost, port);
425
+ }
426
+ else if ((process.env.OPTIMA_DB_TUNNEL || 'ssm').toLowerCase() === 'ssh') {
427
+ setupSSHTunnel(dbHost, port);
428
+ }
429
+ else {
430
+ setupSSMTunnel(dbHost, port);
431
+ }
432
+ reg[dbHost] = port;
433
+ writeTunnelRegistry(reg);
434
+ return port;
435
+ }
140
436
  // ─── psql ───────────────────────────────────────────────────────────────────
141
437
  function findPsqlPath() {
142
438
  try {
@@ -160,14 +456,49 @@ function queryDB(conn, sql) {
160
456
  }).trim();
161
457
  }
162
458
  // ─── High-level connection helpers ──────────────────────────────────────────
459
+ /**
460
+ * 连 cn-prod 某服务库:creds 取自 cn Infisical 的 /shared-secrets/database-users +
461
+ * /database-names(按 prefix,如 AUTH/BILLING),经 buildbox 跳板隧道(动态端口)。
462
+ */
463
+ function connectCnDB(prefix) {
464
+ const token = getCnInfisicalToken();
465
+ const users = getCnSecrets(token, '/shared-secrets/database-users');
466
+ const names = getCnSecrets(token, '/shared-secrets/database-names');
467
+ const user = users[`${prefix}_DB_USER`];
468
+ const password = users[`${prefix}_DB_PASSWORD`];
469
+ const database = names[`${prefix}_DB_NAME`];
470
+ if (!user || !password || !database) {
471
+ throw new Error(`cn DB creds 不全 for ${prefix}(需 ${prefix}_DB_USER/PASSWORD@database-users + ${prefix}_DB_NAME@database-names)。该服务 cn 可能用字面 DATABASE_URL,见 optima-dev-skills#21。`);
472
+ }
473
+ const port = ensureTunnel(CN_RDS_HOST, 'cn-buildbox');
474
+ const conn = { host: 'localhost', port, user, password, database };
475
+ return { query: (sql) => queryDB(conn, sql) };
476
+ }
477
+ /**
478
+ * 连 cn-prod 某服务库,creds 来自该服务 /services/<svc> 的字面/展开 DATABASE_URL。
479
+ * 用于 cred 不在 shared-secrets/database-users 的服务(如 gateway-core)。
480
+ * expandSecretReferences=true 让 cn Infisical 解析 `${...}` 引用为字面值。
481
+ */
482
+ function connectCnDBFromUrl(servicePath) {
483
+ const token = getCnInfisicalToken();
484
+ const secrets = getCnSecrets(token, servicePath, true);
485
+ const dbUrl = secrets['DATABASE_URL'];
486
+ if (!dbUrl)
487
+ throw new Error(`cn DATABASE_URL 未找到 at ${servicePath}`);
488
+ // 注意:DATABASE_URL 含凭证,报错只说事实,不回显值
489
+ if (dbUrl.includes('${'))
490
+ throw new Error(`cn DATABASE_URL 仍含未解析引用(expand 失败)at ${servicePath}`);
491
+ const parsed = parseDatabaseUrl(dbUrl); // host = cn 内网 RDS(经 buildbox 隧道到达)
492
+ const port = ensureTunnel(parsed.host, 'cn-buildbox');
493
+ const conn = { host: 'localhost', port, user: parsed.user, password: parsed.password, database: parsed.database };
494
+ return { query: (sql) => queryDB(conn, sql) };
495
+ }
163
496
  /** Connect to user-auth DB and return a query function. */
164
497
  async function connectAuthDB(env, infisicalConfig, token) {
165
498
  const infisicalEnv = env === 'stage' ? 'staging' : 'prod';
166
499
  const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/shared-secrets/database-users');
167
500
  const host = exports.RDS_HOSTS[env];
168
- const port = env === 'stage' ? 15432 : 15433;
169
- setupSSHTunnel(host, port);
170
- await new Promise(r => setTimeout(r, 1000));
501
+ const port = ensureTunnel(host);
171
502
  const conn = { host: 'localhost', port, user: secrets['AUTH_DB_USER'], password: secrets['AUTH_DB_PASSWORD'], database: 'optima_auth' };
172
503
  return { query: (sql) => queryDB(conn, sql) };
173
504
  }
@@ -179,9 +510,7 @@ async function connectBillingDB(env, infisicalConfig, token) {
179
510
  if (!dbUrl)
180
511
  throw new Error('DATABASE_URL not found for billing service');
181
512
  const parsed = parseDatabaseUrl(dbUrl);
182
- const port = env === 'stage' ? 15434 : 15435;
183
- setupSSHTunnel(parsed.host, port);
184
- await new Promise(r => setTimeout(r, 1000));
513
+ const port = ensureTunnel(parsed.host);
185
514
  const conn = { host: 'localhost', port, user: parsed.user, password: parsed.password, database: parsed.database };
186
515
  return { query: (sql) => queryDB(conn, sql) };
187
516
  }
File without changes
File without changes
File without changes