@optima-chat/dev-skills 0.7.38 → 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.
@@ -34,16 +34,19 @@ 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;
44
- exports.setupSSMTunnel = setupSSMTunnel;
45
- exports.setupTunnel = setupTunnel;
46
+ exports.ensureTunnel = ensureTunnel;
46
47
  exports.queryDB = queryDB;
48
+ exports.connectCnDB = connectCnDB;
49
+ exports.connectCnDBFromUrl = connectCnDBFromUrl;
47
50
  exports.connectAuthDB = connectAuthDB;
48
51
  exports.connectBillingDB = connectBillingDB;
49
52
  exports.resolveUserId = resolveUserId;
@@ -61,6 +64,18 @@ exports.RDS_HOSTS = {
61
64
  const AWS_REGION = 'ap-southeast-1';
62
65
  const BASTION_INSTANCE_ID = 'i-03286fb0a9ce7e6b1';
63
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
+ }
64
79
  // ─── SQL escaping ───────────────────────────────────────────────────────────
65
80
  /** Escape a string value for safe inclusion in SQL single-quoted literals. */
66
81
  function escapeSQL(value) {
@@ -68,7 +83,8 @@ function escapeSQL(value) {
68
83
  }
69
84
  // ─── GitHub Variables ───────────────────────────────────────────────────────
70
85
  function getGitHubVariable(name) {
71
- 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();
72
88
  }
73
89
  // ─── Infisical ──────────────────────────────────────────────────────────────
74
90
  function getInfisicalConfig() {
@@ -92,12 +108,102 @@ function getInfisicalSecrets(config, token, environment, secretPath) {
92
108
  }
93
109
  return secrets;
94
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
+ }
95
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
+ */
96
176
  function parseDatabaseUrl(url) {
97
- const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
98
- if (!match)
99
- throw new Error('Failed to parse DATABASE_URL (format: postgresql://user:pass@host:port/db)');
100
- 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
+ };
101
207
  }
102
208
  // ─── SSH tunnel ─────────────────────────────────────────────────────────────
103
209
  // 端口被占不等于 tunnel 还能转发:AWS bastion idle 断连后,本地 ssh 进程还活着、
@@ -112,27 +218,55 @@ function isTunnelHealthy(localPort) {
112
218
  return false;
113
219
  }
114
220
  }
115
- function killOrphanTunnel(localPort) {
221
+ function portPids(localPort) {
116
222
  try {
117
- const pids = (0, child_process_1.execSync)(`lsof -ti:${localPort}`, { encoding: 'utf-8' }).trim();
118
- if (pids)
119
- (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 [];
120
227
  }
121
- catch { /* nothing to kill */ }
122
228
  }
123
- function setupSSHTunnel(dbHost, localPort) {
124
- let portInUse = false;
229
+ // lsof 只能看到「有进程 bind」的端口。Docker 发布端口走 iptables DNAT 时主机上
230
+ // 没有进程绑定(lsof 空),但流量照样被内核截给容器 —— 在这种端口上建隧道,
231
+ // psql 连的还是容器里的库。所以空闲判定必须用客户端视角:真去 connect,有人
232
+ // 应答(无论进程还是 DNAT)就算被占。
233
+ function isPortResponding(localPort) {
125
234
  try {
126
- (0, child_process_1.execSync)(`lsof -ti:${localPort}`, { stdio: 'ignore' });
127
- 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;
128
239
  }
129
- catch { /* free */ }
130
- if (portInUse) {
131
- if (isTunnelHealthy(localPort))
132
- return;
133
- console.log(`! SSH tunnel on port ${localPort} not responding (zombie), replacing...`);
134
- killOrphanTunnel(localPort);
240
+ catch {
241
+ return false;
135
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) {
136
270
  const sshKeyPath = `${process.env.HOME}/.ssh/optima-ec2-key`;
137
271
  if (!fs.existsSync(sshKeyPath))
138
272
  throw new Error(`SSH key not found: ${sshKeyPath}. Please obtain optima-ec2-key from xbfool.`);
@@ -168,18 +302,6 @@ function assertSSMPrereqs() {
168
302
  * (the ~2s SSM cold start is paid once, then every query is just a round-trip).
169
303
  */
170
304
  function setupSSMTunnel(dbHost, localPort) {
171
- let portInUse = false;
172
- try {
173
- (0, child_process_1.execSync)(`lsof -ti:${localPort}`, { stdio: 'ignore' });
174
- portInUse = true;
175
- }
176
- catch { /* free */ }
177
- if (portInUse) {
178
- if (isTunnelHealthy(localPort))
179
- return; // warm reuse — no cold start
180
- console.log(`! Tunnel on port ${localPort} not responding (zombie), replacing...`);
181
- killOrphanTunnel(localPort);
182
- }
183
305
  assertSSMPrereqs();
184
306
  console.log(`Creating SSM tunnel: localhost:${localPort} -> ${BASTION_INSTANCE_ID} -> ${dbHost}:5432`);
185
307
  const logFile = `${os.tmpdir()}/optima-ssm-tunnel-${localPort}.log`;
@@ -210,16 +332,106 @@ function setupSSMTunnel(dbHost, localPort) {
210
332
  throw new Error(`SSM tunnel on port ${localPort} did not become ready within 20s.\n--- ${logFile} ---\n${tail}`);
211
333
  }
212
334
  /**
213
- * Open a DB tunnel through the shared bastion. Defaults to SSM (no public port 22);
214
- * set OPTIMA_DB_TUNNEL=ssh to fall back to the legacy SSH tunnel.
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 无公网端点)。
215
407
  */
216
- function setupTunnel(dbHost, localPort) {
217
- if ((process.env.OPTIMA_DB_TUNNEL || 'ssm').toLowerCase() === 'ssh') {
218
- setupSSHTunnel(dbHost, localPort);
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);
219
428
  }
220
429
  else {
221
- setupSSMTunnel(dbHost, localPort);
430
+ setupSSMTunnel(dbHost, port);
222
431
  }
432
+ reg[dbHost] = port;
433
+ writeTunnelRegistry(reg);
434
+ return port;
223
435
  }
224
436
  // ─── psql ───────────────────────────────────────────────────────────────────
225
437
  function findPsqlPath() {
@@ -244,13 +456,49 @@ function queryDB(conn, sql) {
244
456
  }).trim();
245
457
  }
246
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
+ }
247
496
  /** Connect to user-auth DB and return a query function. */
248
497
  async function connectAuthDB(env, infisicalConfig, token) {
249
498
  const infisicalEnv = env === 'stage' ? 'staging' : 'prod';
250
499
  const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/shared-secrets/database-users');
251
500
  const host = exports.RDS_HOSTS[env];
252
- const port = env === 'stage' ? 15432 : 15433;
253
- setupTunnel(host, port);
501
+ const port = ensureTunnel(host);
254
502
  const conn = { host: 'localhost', port, user: secrets['AUTH_DB_USER'], password: secrets['AUTH_DB_PASSWORD'], database: 'optima_auth' };
255
503
  return { query: (sql) => queryDB(conn, sql) };
256
504
  }
@@ -262,8 +510,7 @@ async function connectBillingDB(env, infisicalConfig, token) {
262
510
  if (!dbUrl)
263
511
  throw new Error('DATABASE_URL not found for billing service');
264
512
  const parsed = parseDatabaseUrl(dbUrl);
265
- const port = env === 'stage' ? 15434 : 15435;
266
- setupTunnel(parsed.host, port);
513
+ const port = ensureTunnel(parsed.host);
267
514
  const conn = { host: 'localhost', port, user: parsed.user, password: parsed.password, database: parsed.database };
268
515
  return { query: (sql) => queryDB(conn, sql) };
269
516
  }
File without changes
File without changes
File without changes
@@ -1,23 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
+ // P15 D8b(optima-billing docs/2026-06-11-p15-wallet-sunset-spec.md):
5
+ // USD 钱包已退役——原「SSH 直写 usd_wallets.granted_balance_micros」作废,
6
+ // 改调 billing 服务态端点(grantCredits → bonus 积分)。
7
+ // ⚠️ 语义变化:旧 wallet granted 无期限;积分 bonus 桶标准 30 天有效期。
8
+ const crypto_1 = require("crypto");
4
9
  const db_utils_1 = require("./db-utils");
10
+ const billing_http_1 = require("./billing-http");
5
11
  function parseArgs(args) {
6
12
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
7
13
  console.log(`Usage: optima-grant-balance <email> --amount <usd> [options]
8
14
 
9
- Add USD balance to a user's wallet (granted_balance_micros).
15
+ Grant credits to a user (bonus bucket, expires in 30 days).
10
16
  Used for promotional grants, compensation, referral rewards, etc.
17
+ $1 = 700 credits (P15 unified ledger; the USD wallet is retired).
11
18
 
12
19
  Options:
13
- --amount <usd> USD amount to grant (required, e.g. 5 for $5.00)
20
+ --amount <usd> USD amount to grant (required, e.g. 5 for $5.00 = 3500 credits)
14
21
  --description <text> Description for audit trail (optional)
15
- --env <env> Environment: stage, prod (default: stage)
22
+ --env <env> Environment: stage, prod, cn-prod (default: stage)
16
23
  -h, --help Show this help
17
24
 
18
25
  Examples:
19
26
  optima-grant-balance user@example.com --amount 5 --env prod
20
- optima-grant-balance user@example.com --amount 10 --description "Service outage compensation"`);
27
+ optima-grant-balance user@example.com --amount 10 --description "Service outage compensation"
28
+ optima-grant-balance user@example.com --amount 1 --env cn-prod # ¥-priced env, still USD input ($1 = 700 credits)`);
21
29
  process.exit(0);
22
30
  }
23
31
  const email = args[0];
@@ -39,51 +47,35 @@ Examples:
39
47
  console.error('--amount is required and must be > 0 (USD)');
40
48
  process.exit(1);
41
49
  }
42
- if (!['stage', 'prod'].includes(env)) {
43
- console.error('Env must be stage or prod (billing DB not available in CI)');
44
- process.exit(1);
45
- }
50
+ (0, billing_http_1.validateEnvCnProd)(env);
46
51
  return { email, amountUsd, description, env };
47
52
  }
48
53
  async function main() {
49
54
  const { email, amountUsd, description, env } = parseArgs(process.argv.slice(2));
50
- const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
51
- const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
52
- // 1 USD = 1,000,000 micros. Round to integer micros.
53
- const amountMicros = Math.round(amountUsd * 1000000);
54
- console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} to ${email} [${env.toUpperCase()}]\n`);
55
+ console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${email} [${env.toUpperCase()}]\n`);
55
56
  if (description)
56
57
  console.log(` Reason: ${description}`);
57
- const userId = await (0, db_utils_1.resolveUserId)(email, env, infisicalConfig, token);
58
- const billing = await (0, db_utils_1.connectBillingDB)(env, infisicalConfig, token);
59
- const bq = billing.query;
60
- const now = new Date().toISOString();
61
- const safeUserId = (0, db_utils_1.escapeSQL)(userId);
62
- console.log(`Granting to wallet...`);
63
- const txSQL = `
64
- BEGIN;
65
-
66
- -- Ensure wallet exists
67
- INSERT INTO usd_wallets (id, user_id, balance_micros, reserved_micros, granted_balance_micros, created_at, updated_at)
68
- VALUES (gen_random_uuid(), '${safeUserId}', 0, 0, 0, '${now}', '${now}')
69
- ON CONFLICT (user_id) DO NOTHING;
70
-
71
- -- Add to granted balance
72
- UPDATE usd_wallets SET granted_balance_micros = granted_balance_micros + ${amountMicros}, updated_at = '${now}'
73
- WHERE user_id = '${safeUserId}';
74
-
75
- -- Record topup for audit trail (source='admin_grant' aligns with billing service convention)
76
- INSERT INTO usd_wallet_topups (id, wallet_id, amount_micros, service_fee_micros, net_credit_micros, status, source, service_namespace, created_at, completed_at)
77
- SELECT gen_random_uuid(), w.id, ${amountMicros}, 0, ${amountMicros}, 'completed', 'admin_grant', 'platform', '${now}', '${now}'
78
- FROM usd_wallets w WHERE w.user_id = '${safeUserId}';
79
-
80
- COMMIT;
81
- `.trim();
82
- bq(txSQL);
83
- console.log(`✓ Granted $${amountUsd.toFixed(2)} to wallet`);
84
- const balanceMicros = bq(`SELECT granted_balance_micros FROM usd_wallets WHERE user_id='${safeUserId}'`);
85
- const balanceUsd = (parseInt(balanceMicros, 10) / 1000000).toFixed(2);
86
- console.log(`\n✅ Done! ${email} now has $${balanceUsd} granted balance\n`);
58
+ // cn-prod has no SSH tunnel into the Aliyun RDS — resolve via user-auth's
59
+ // internal lookup API instead of the direct SQL path.
60
+ let userId;
61
+ if (env === 'cn-prod') {
62
+ userId = await (0, billing_http_1.resolveUserIdByEmail)(env, email);
63
+ }
64
+ else {
65
+ const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
66
+ const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
67
+ userId = await (0, db_utils_1.resolveUserId)(email, env, infisicalConfig, token);
68
+ }
69
+ // 幂等键 per-invocation 生成、callBilling 5xx retry 复用同 body —— 「已
70
+ // commit 但响应 5xx」场景重试不双发(billing spec R2-M3)。
71
+ const { body } = await (0, billing_http_1.callBilling)(env, 'POST', '/api/billing/admin/grant-credits', {
72
+ userId,
73
+ amountUsd,
74
+ description: description ?? undefined,
75
+ idempotencyKey: `dev-skills-grant:${(0, crypto_1.randomUUID)()}`,
76
+ });
77
+ console.log(`✓ Granted ${body.credits} credits (lot ${body.lotId})`);
78
+ console.log(`\n✅ Done! ${email} received ${body.credits} bonus credits (expires in 30 days)\n`);
87
79
  }
88
80
  main().catch(error => {
89
81
  console.error('\n❌ Error:', error.message);