@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,6 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { getInfisicalConfig, getInfisicalToken, resolveUserId, connectBillingDB, escapeSQL } from './db-utils';
3
+ // P15 D8b:USD 钱包退役——原「SSH 直写 subscriptions/usd_wallets/token_quotas」
4
+ // 作废,改调 billing 服务态端点(与用户态 /api/admin/grant-subscription 同
5
+ // 业务体:supersede 旧授予 + 实得 credits + token quota,重试双发不双倍)。
6
+ import { getInfisicalConfig, getInfisicalToken, resolveUserId } from './db-utils';
7
+ import { callBilling, resolveUserIdByEmail, validateEnvCnProd } from './billing-http';
8
+
9
+ // cn-prod sells the CNY-priced -cn plans (P12); the bare USD plan ids also
10
+ // exist in the cn DB, so a per-env whitelist (not billing-side validation)
11
+ // is what prevents accidentally granting a USD-priced plan to a CN user.
12
+ const PLANS_BY_ENV: Record<string, string[]> = {
13
+ stage: ['trial', 'starter', 'pro', 'enterprise'],
14
+ prod: ['trial', 'starter', 'pro', 'enterprise'],
15
+ 'cn-prod': ['trial', 'starter-cn', 'pro-cn', 'enterprise-cn'],
16
+ };
4
17
 
5
18
  function parseArgs(args: string[]): { email: string; plan: string; months: number; env: string } {
6
19
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
@@ -8,14 +21,15 @@ function parseArgs(args: string[]): { email: string; plan: string; months: numbe
8
21
 
9
22
  Options:
10
23
  --plan <id> Plan: trial, starter, pro, enterprise (default: pro)
24
+ cn-prod plans: trial, starter-cn, pro-cn, enterprise-cn (default: pro-cn)
11
25
  --months <n> Duration in months (default: 1)
12
- --env <env> Environment: stage, prod (default: stage)
26
+ --env <env> Environment: stage, prod, cn-prod (default: stage)
13
27
  -h, --help Show this help`);
14
28
  process.exit(0);
15
29
  }
16
30
 
17
31
  const email = args[0];
18
- let plan = 'pro';
32
+ let plan: string | null = null;
19
33
  let months = 1;
20
34
  let env = 'stage';
21
35
 
@@ -25,106 +39,50 @@ Options:
25
39
  else if (args[i] === '--env' && args[i + 1]) { env = args[++i]; }
26
40
  }
27
41
 
28
- if (!['trial', 'starter', 'pro', 'enterprise'].includes(plan)) {
29
- console.error(`Unknown plan: ${plan}. Available: trial, starter, pro, enterprise`);
42
+ validateEnvCnProd(env);
43
+ plan = plan ?? (env === 'cn-prod' ? 'pro-cn' : 'pro');
44
+ const allowed = PLANS_BY_ENV[env];
45
+ if (!allowed.includes(plan)) {
46
+ console.error(`Unknown plan for ${env}: ${plan}. Available: ${allowed.join(', ')}`);
30
47
  process.exit(1);
31
48
  }
32
49
  if (months < 1) { console.error('Months must be >= 1'); process.exit(1); }
33
- if (!['stage', 'prod'].includes(env)) { console.error('Env must be stage or prod (billing DB not available in CI)'); process.exit(1); }
34
50
 
35
51
  return { email, plan, months, env };
36
52
  }
37
53
 
38
54
  async function main() {
39
55
  const { email, plan, months, env } = parseArgs(process.argv.slice(2));
40
- const infisicalConfig = getInfisicalConfig();
41
- const token = getInfisicalToken(infisicalConfig);
42
56
 
43
57
  console.log(`\n🎁 Granting ${plan} subscription to ${email} for ${months} month(s) [${env.toUpperCase()}]\n`);
44
58
 
45
- const userId = await resolveUserId(email, env, infisicalConfig, token);
46
- const billing = await connectBillingDB(env, infisicalConfig, token);
47
- const bq = billing.query;
48
-
49
- // Read plan config from DB
50
- console.log(`Loading plan config: ${plan}`);
51
- const planRow = bq(`SELECT name, monthly_credits, session_token_limit, weekly_token_limit FROM plans WHERE id='${escapeSQL(plan)}'`);
52
- if (!planRow) { console.error(`❌ Plan not found in DB: ${plan}`); process.exit(1); }
53
-
54
- const [planName, monthlyCreditsStr, sessionTokenLimitStr, weeklyTokenLimitStr] = planRow.split('|');
55
- const monthlyCredits = parseInt(monthlyCreditsStr, 10);
56
- const grantMicros = monthlyCredits * 10000; // 1 credit = $0.01 = 10,000 micros
57
- const sessionTokenLimit = parseInt(sessionTokenLimitStr, 10);
58
- const weeklyTokenLimit = parseInt(weeklyTokenLimitStr, 10);
59
- console.log(`✓ Plan: ${planName} (grant: $${(grantMicros / 1000000).toFixed(2)}, session: ${sessionTokenLimit.toLocaleString()}, weekly: ${weeklyTokenLimit.toLocaleString()})`);
60
-
61
- // Execute all mutations in a single transaction
62
- const now = new Date().toISOString();
63
- const periodEnd = new Date();
64
- periodEnd.setMonth(periodEnd.getMonth() + months);
65
- const periodEndISO = periodEnd.toISOString();
66
- const sessionEnd = new Date(new Date().getTime() + 5 * 60 * 60 * 1000).toISOString();
67
- const weekEnd = new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000).toISOString();
68
-
69
- const safeUserId = escapeSQL(userId);
70
- const safePlan = escapeSQL(plan);
71
- const safePlanName = escapeSQL(planName);
72
-
73
- console.log('Executing transaction...');
74
- const txSQL = `
75
- BEGIN;
76
-
77
- -- Cancel active subscriptions
78
- UPDATE subscriptions SET status='canceled', canceled_at='${now}'
79
- WHERE user_id='${safeUserId}' AND status IN ('active','trialing');
80
-
81
- -- Create new subscription
82
- INSERT INTO subscriptions (id, user_id, plan_id, status, billing_interval, current_period_start, current_period_end, created_at, updated_at)
83
- VALUES (concat('sub_gift_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'active', 'monthly', '${now}', '${periodEndISO}', '${now}', '${now}');
84
-
85
- -- Ensure wallet exists (upsert)
86
- INSERT INTO usd_wallets (id, user_id, balance_micros, reserved_micros, granted_balance_micros, created_at, updated_at)
87
- VALUES (gen_random_uuid(), '${safeUserId}', 0, 0, 0, '${now}', '${now}')
88
- ON CONFLICT (user_id) DO NOTHING;
89
-
90
- -- Reset granted balance and set new grant
91
- UPDATE usd_wallets SET granted_balance_micros = ${grantMicros}, updated_at = '${now}'
92
- WHERE user_id = '${safeUserId}';
93
-
94
- -- Record topup for audit trail
95
- INSERT INTO usd_wallet_topups (id, wallet_id, amount_micros, service_fee_micros, net_credit_micros, status, source, service_namespace, created_at, completed_at)
96
- SELECT gen_random_uuid(), w.id, ${grantMicros}, 0, ${grantMicros}, 'completed', 'subscription_grant', 'platform', '${now}', '${now}'
97
- FROM usd_wallets w WHERE w.user_id = '${safeUserId}';
98
-
99
- -- Update existing active session quota, or insert new one if none exists
100
- UPDATE token_quotas SET plan_id='${safePlan}', monthly_limit=${sessionTokenLimit}, updated_at='${now}'
101
- WHERE user_id='${safeUserId}' AND period_type='session' AND period_end > '${now}';
102
-
103
- INSERT INTO token_quotas (id, user_id, plan_id, period_type, monthly_limit, monthly_used, period_start, period_end, created_at, updated_at)
104
- SELECT concat('tq_sess_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'session', ${sessionTokenLimit}, 0, '${now}', '${sessionEnd}', '${now}', '${now}'
105
- WHERE NOT EXISTS (SELECT 1 FROM token_quotas WHERE user_id='${safeUserId}' AND period_type='session' AND period_end > '${now}');
106
-
107
- -- Update existing active weekly quota, or insert new one if none exists
108
- UPDATE token_quotas SET plan_id='${safePlan}', monthly_limit=${weeklyTokenLimit}, updated_at='${now}'
109
- WHERE user_id='${safeUserId}' AND period_type='weekly' AND period_end > '${now}';
110
-
111
- INSERT INTO token_quotas (id, user_id, plan_id, period_type, monthly_limit, monthly_used, period_start, period_end, created_at, updated_at)
112
- SELECT concat('tq_week_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'weekly', ${weeklyTokenLimit}, 0, '${now}', '${weekEnd}', '${now}', '${now}'
113
- WHERE NOT EXISTS (SELECT 1 FROM token_quotas WHERE user_id='${safeUserId}' AND period_type='weekly' AND period_end > '${now}');
114
-
115
- COMMIT;
116
- `.trim();
117
-
118
- bq(txSQL);
119
-
120
- console.log('✓ Old subscriptions canceled');
121
- console.log(`✓ ${planName} subscription created (expires: ${periodEnd.toLocaleDateString()})`);
122
- if (grantMicros > 0) {
123
- console.log(`✓ Wallet granted $${(grantMicros / 1000000).toFixed(2)} (${monthlyCredits} credits equivalent)`);
59
+ // cn-prod has no SSH tunnel into the Aliyun RDS — resolve via user-auth's
60
+ // internal lookup API instead of the direct SQL path.
61
+ let userId: string;
62
+ if (env === 'cn-prod') {
63
+ userId = await resolveUserIdByEmail(env, email);
64
+ } else {
65
+ const infisicalConfig = getInfisicalConfig();
66
+ const token = getInfisicalToken(infisicalConfig);
67
+ userId = await resolveUserId(email, env, infisicalConfig, token);
124
68
  }
125
- console.log(`✓ Token quotas updated (session: ${sessionTokenLimit.toLocaleString()}, weekly: ${weeklyTokenLimit.toLocaleString()})`);
126
69
 
127
- console.log(`\n✅ Done! ${email} now has ${planName} plan until ${periodEnd.toLocaleDateString()}\n`);
70
+ const { body } = await callBilling<{
71
+ success: boolean;
72
+ subscriptionId: string;
73
+ planId: string;
74
+ credits: number;
75
+ sessionTokenLimit: number;
76
+ weeklyTokenLimit: number;
77
+ expiresAt: string;
78
+ warning?: string;
79
+ }>(env, 'POST', '/api/billing/admin/grant-subscription', { userId, planId: plan, months });
80
+
81
+ console.log(`✓ Subscription ${body.subscriptionId} (${body.planId})`);
82
+ console.log(`✓ Credits: ${body.credits.toLocaleString()} (expires ${body.expiresAt})`);
83
+ console.log(`✓ Token limits: session ${body.sessionTokenLimit.toLocaleString()} / weekly ${body.weeklyTokenLimit.toLocaleString()}`);
84
+ if (body.warning) console.log(`⚠️ ${body.warning}`);
85
+ console.log(`\n✅ Done! ${email} now has ${body.planId} until ${body.expiresAt}\n`);
128
86
  }
129
87
 
130
88
  main().catch(error => {
@@ -2,13 +2,7 @@
2
2
 
3
3
  import { execSync } from 'child_process';
4
4
  import * as fs from 'fs';
5
-
6
- interface InfisicalConfig {
7
- url: string;
8
- clientId: string;
9
- clientSecret: string;
10
- projectId: string;
11
- }
5
+ import { ensureTunnel, getGitHubVariable, getInfisicalConfig, getInfisicalToken, getInfisicalSecrets, parseDatabaseUrl, isCnEnv, connectCnDB, connectCnDBFromUrl } from './db-utils';
12
6
 
13
7
  interface DatabaseConfig {
14
8
  host: string;
@@ -96,80 +90,8 @@ const RDS_HOSTS = {
96
90
  prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com'
97
91
  };
98
92
 
99
- // 统一使用 BI Data ARM Host 作为跳板机
100
- const EC2_HOST = '3.0.210.113';
101
-
102
- function parseDatabaseUrl(url: string): { user: string; password: string; host: string; port: number; database: string } {
103
- // postgresql://user:password@host:port/database?params
104
- const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
105
- if (!match) {
106
- throw new Error(`Failed to parse DATABASE_URL: ${url}`);
107
- }
108
- return {
109
- user: decodeURIComponent(match[1]),
110
- password: decodeURIComponent(match[2]),
111
- host: match[3],
112
- port: parseInt(match[4]),
113
- database: match[5]
114
- };
115
- }
116
-
117
- function getGitHubVariable(name: string): string {
118
- return execSync(`gh variable get ${name} -R Optima-Chat/optima-dev-skills`, { encoding: 'utf-8' }).trim();
119
- }
120
-
121
- function getInfisicalConfig(): InfisicalConfig {
122
- return {
123
- url: getGitHubVariable('INFISICAL_URL'),
124
- clientId: getGitHubVariable('INFISICAL_CLIENT_ID'),
125
- clientSecret: getGitHubVariable('INFISICAL_CLIENT_SECRET'),
126
- projectId: getGitHubVariable('INFISICAL_PROJECT_ID')
127
- };
128
- }
129
-
130
- function getInfisicalToken(config: InfisicalConfig): string {
131
- const response = execSync(
132
- `curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`,
133
- { encoding: 'utf-8' }
134
- );
135
- return JSON.parse(response).accessToken;
136
- }
137
-
138
- function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record<string, string> {
139
- const response = execSync(
140
- `curl -s "${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${secretPath}" -H "Authorization: Bearer ${token}"`,
141
- { encoding: 'utf-8' }
142
- );
143
- const data = JSON.parse(response);
144
- const secrets: Record<string, string> = {};
145
- for (const secret of data.secrets || []) {
146
- secrets[secret.secretKey] = secret.secretValue;
147
- }
148
- return secrets;
149
- }
150
-
151
- function setupSSHTunnel(ec2Host: string, dbHost: string, localPort: number): void {
152
- // 检查是否已有隧道
153
- try {
154
- execSync(`lsof -ti:${localPort}`, { stdio: 'ignore' });
155
- console.log(`✓ SSH tunnel already exists on port ${localPort}`);
156
- return;
157
- } catch {
158
- // 端口未占用,创建隧道
159
- }
160
-
161
- const sshKeyPath = `${process.env.HOME}/.ssh/optima-ec2-key`;
162
- if (!fs.existsSync(sshKeyPath)) {
163
- throw new Error(`SSH key not found: ${sshKeyPath}. Please obtain optima-ec2-key from xbfool.`);
164
- }
165
-
166
- console.log(`Creating SSH tunnel: localhost:${localPort} -> ${ec2Host} -> ${dbHost}:5432`);
167
- execSync(
168
- `ssh -i ${sshKeyPath} -f -N -o StrictHostKeyChecking=no -L ${localPort}:${dbHost}:5432 ec2-user@${ec2Host}`,
169
- { stdio: 'inherit' }
170
- );
171
- console.log(`✓ SSH tunnel established on port ${localPort}`);
172
- }
93
+ // parseDatabaseUrl 统一用 db-utils 的实现:右切 userinfo 容忍密码特殊字符,
94
+ // 且报错不回显 URL(这里曾把含密码的完整 URL 打进错误信息)。
173
95
 
174
96
  function findPsqlPath(): string {
175
97
  // 1. 优先从 PATH 中查找
@@ -235,7 +157,7 @@ async function main() {
235
157
  console.error('Usage: query-db.ts <service> <sql> [environment]');
236
158
  console.error('');
237
159
  console.error('Services: commerce-backend, user-auth, agentic-chat, bi-backend, session-gateway, gateway-core, optima-logistics, billing, ads-backend, amazon-backend, browser-backend, shopify-backend, optima-generation, optima-sentinel');
238
- console.error('Environments: ci (default), stage, prod');
160
+ console.error('Environments: ci (default), stage, prod, cn (阿里云 cn-prod)');
239
161
  console.error('');
240
162
  console.error('Example: query-db.ts user-auth "SELECT COUNT(*) FROM users" prod');
241
163
  process.exit(1);
@@ -249,6 +171,26 @@ async function main() {
249
171
  process.exit(1);
250
172
  }
251
173
 
174
+ // cn-prod(阿里云):独立 Infisical + 经 buildbox 跳板连内网 RDS(动态端口隧道)。
175
+ // 两类 cred:① shared-secrets/database-users(按 prefix)② 服务自己的 DATABASE_URL(展开引用)。
176
+ if (isCnEnv(environment)) {
177
+ const prodCfg = SERVICE_DB_MAP[service as keyof typeof SERVICE_DB_MAP].prod as any;
178
+ let db: { query: (sql: string) => string };
179
+ if (prodCfg?.userKey) {
180
+ const prefix = prodCfg.userKey.replace(/_DB_USER$/, '');
181
+ console.log(`\n🔍 Querying ${service} (CN-PROD, prefix ${prefix})...`);
182
+ db = connectCnDB(prefix);
183
+ } else if (prodCfg?.databaseUrlPath) {
184
+ console.log(`\n🔍 Querying ${service} (CN-PROD, DATABASE_URL @ ${prodCfg.databaseUrlPath})...`);
185
+ db = connectCnDBFromUrl(prodCfg.databaseUrlPath);
186
+ } else {
187
+ console.error(`cn-prod query 暂不支持 ${service}(既无 userKey 也无 databaseUrlPath)。见 optima-dev-skills#21。`);
188
+ process.exit(1);
189
+ }
190
+ console.log('\n' + db.query(sql));
191
+ return;
192
+ }
193
+
252
194
  const serviceConfig = SERVICE_DB_MAP[service as keyof typeof SERVICE_DB_MAP][environment as 'ci' | 'stage' | 'prod'];
253
195
 
254
196
  if (!serviceConfig) {
@@ -321,12 +263,7 @@ async function main() {
321
263
  }
322
264
  }
323
265
 
324
- const localPort = environment === 'stage' ? 15432 : 15433;
325
-
326
- setupSSHTunnel(EC2_HOST, dbHost, localPort);
327
-
328
- // 等待隧道建立
329
- await new Promise(resolve => setTimeout(resolve, 1000));
266
+ const localPort = ensureTunnel(dbHost);
330
267
 
331
268
  const result = queryDatabase('localhost', localPort, dbUser, dbPassword, database, sql);
332
269
  console.log('\n' + result);
@@ -1,13 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execSync } from 'child_process';
4
-
5
- interface InfisicalConfig {
6
- url: string;
7
- clientId: string;
8
- clientSecret: string;
9
- projectId: string;
10
- }
4
+ import { getInfisicalConfig, getInfisicalToken, InfisicalConfig } from './db-utils';
11
5
 
12
6
  // 支持的服务列表(Infisical 路径为 /services/<service-name>)
13
7
  const SUPPORTED_SERVICES = [
@@ -44,27 +38,8 @@ const ENV_MAP: Record<string, string> = {
44
38
  prod: 'prod'
45
39
  };
46
40
 
47
- function getGitHubVariable(name: string): string {
48
- return execSync(`gh variable get ${name} -R Optima-Chat/optima-dev-skills`, { encoding: 'utf-8' }).trim();
49
- }
50
-
51
- function getInfisicalConfig(): InfisicalConfig {
52
- return {
53
- url: getGitHubVariable('INFISICAL_URL'),
54
- clientId: getGitHubVariable('INFISICAL_CLIENT_ID'),
55
- clientSecret: getGitHubVariable('INFISICAL_CLIENT_SECRET'),
56
- projectId: getGitHubVariable('INFISICAL_PROJECT_ID')
57
- };
58
- }
59
-
60
- function getInfisicalToken(config: InfisicalConfig): string {
61
- const response = execSync(
62
- `curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`,
63
- { encoding: 'utf-8' }
64
- );
65
- return JSON.parse(response).accessToken;
66
- }
67
-
41
+ // NOTE: getInfisicalSecrets is kept local because it encodes secretPath (encodeURIComponent),
42
+ // unlike db-utils' raw-path variant; getGitHubVariable/getInfisicalConfig/getInfisicalToken are shared from db-utils.
68
43
  function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record<string, string> {
69
44
  const response = execSync(
70
45
  `curl -s "${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${encodeURIComponent(secretPath)}" -H "Authorization: Bearer ${token}"`,
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * optima-verify-health —— 服务上线健康探针(L1 DNS / L2 TLS / L3-L5 /health)。
4
+ *
5
+ * 逐层探、逐层报,逐层 pass 才算上线成功。零依赖(纯 Node 内置)。
6
+ * 配套静态合规审计 audit-probe.py 在 optima-terraform 仓 docs/cn-prod/probes/(读 terraform 文件,绑仓不搬)。
7
+ *
8
+ * L1 DNS : FQDN 能解析到 IP
9
+ * L2 TLS : 443 握手 + 证书链/有效期/SNI(SAN)匹配
10
+ * L3 /health : optima-core 风格 /health,顶层 status 健康
11
+ * L4 真部署 : 解析 gitCommit;传 --expect-commit <sha> 时比对(证明跑的是这次镜像)
12
+ * L5 依赖 : checks[*] 全绿(DB/Redis/上游由服务在 health handler 自注册)
13
+ *
14
+ * ⚠️ optima-core 的 JS 与 Py 两套 /health schema 不一致,都兼容:
15
+ * JS : gitCommit(camel) / status∈{healthy,unhealthy} / 不健康返 503 / 有 gitBranch
16
+ * Py : git_commit(snake) / status∈{healthy,degraded} / 永远 200 / check 可 timeout|error / 无 git_branch
17
+ *
18
+ * 用法:
19
+ * optima-verify-health user-auth # 默认 cn-prod
20
+ * optima-verify-health user-auth --env prod # 环境 stage|prod|cn|all
21
+ * optima-verify-health --all --env all # stage/prod/cn 三环境矩阵
22
+ * optima-verify-health gateway-core --expect-commit a1b2c3d
23
+ * optima-verify-health --url https://auth-cn.optima.chat/health
24
+ * optima-verify-health --all --json # 机器可读,接 CI
25
+ * optima-verify-health --all --strict # warn 也算不通过
26
+ * 退出码:fail → 非 0;warn 默认放行(0),--strict 让 warn 也非 0。
27
+ */
28
+ import { promises as dns } from 'node:dns';
29
+ import * as tls from 'node:tls';
30
+ import * as https from 'node:https';
31
+
32
+ type Env = 'stage' | 'prod' | 'cn';
33
+ interface SvcCfg { path: string; stage?: string; prod?: string; cn?: string; cn_path?: string; }
34
+
35
+ // 服务 × 环境 FQDN 表。某服务某环境没部署 → 该 env 键缺省,探时跳过。
36
+ // cn-prod 真实 subdomain 抄自 optima-terraform 的 stack main.tf(非文档表,如 agentic-chat 真名是 agentic-chat-cn 不是 ai-cn)。
37
+ const SERVICES: Record<string, SvcCfg> = {
38
+ 'user-auth': { path: '/health', stage: 'auth-stage.optima.onl', prod: 'auth.optima.onl', cn: 'auth-cn.optima.chat' },
39
+ 'agentic-chat': { path: '/api/health', stage: 'ai-stage.optima.onl', prod: 'ai.optima.onl', cn: 'agentic-chat-cn.optima.chat' },
40
+ 'commerce-backend': { path: '/health', stage: 'api-stage.optima.onl', prod: 'api.optima.onl', cn: 'api-cn.optima.chat', cn_path: '/health/live' },
41
+ 'mcp-host': { path: '/health', stage: 'mcp-stage.optima.onl', prod: 'mcp.optima.onl' },
42
+ 'gateway-core': { path: '/health', cn: 'gw-cn.optima.chat' },
43
+ 'optima-scout': { path: '/health', cn: 'scout-cn.optima.chat' },
44
+ 'optima-skills': { path: '/health', cn: 'skills-cn.optima.chat' },
45
+ };
46
+ const ENVS: Env[] = ['stage', 'prod', 'cn'];
47
+
48
+ const G = '\x1b[32m', R = '\x1b[31m', Y = '\x1b[33m', B = '\x1b[34m', N = '\x1b[0m';
49
+ const MARK: Record<string, string> = { ok: `${G}✅${N}`, fail: `${R}❌${N}`, warn: `${Y}⚠️ ${N}`, na: `${B}··${N}` };
50
+ type Result = 'ok' | 'warn' | 'fail';
51
+ interface Layer { layer: string; result: Result; detail: string; }
52
+
53
+ const verdict = (ls: Layer[]): 'pass' | 'warn' | 'fail' =>
54
+ ls.some((l) => l.result === 'fail') ? 'fail' : ls.some((l) => l.result === 'warn') ? 'warn' : 'pass';
55
+
56
+ const VTXT: Record<string, string> = {
57
+ pass: `${G}上线成功(L1-L5 全绿)${N}`,
58
+ warn: `${Y}存活但有告警(见上 ⚠️;非阻塞)${N}`,
59
+ fail: `${R}未通过 — 见上 ❌${N}`,
60
+ };
61
+
62
+ function tlsCheck(host: string, timeout = 10000): Promise<{ ok: boolean; detail: string; result: Result }> {
63
+ return new Promise((resolve) => {
64
+ const sock = tls.connect({ host, port: 443, servername: host, timeout }, () => {
65
+ const cert = sock.getPeerCertificate();
66
+ const days = Math.floor((new Date(cert.valid_to).getTime() - Date.now()) / 86400000);
67
+ const sans = (cert.subjectaltname || '').split(',').map((s) => s.trim().replace(/^DNS:/, '')).slice(0, 3);
68
+ sock.end();
69
+ resolve({ ok: true, result: days > 7 ? 'ok' : 'warn',
70
+ detail: `证书 ${days}d 后过期, SAN=${sans.join(',')}` + (days > 7 ? '' : ` (剩 ${days}d!)`) });
71
+ });
72
+ sock.on('error', (e: Error) => resolve({ ok: false, result: 'fail', detail: `握手/证书失败: ${e.message}` }));
73
+ sock.on('timeout', () => { sock.destroy(); resolve({ ok: false, result: 'fail', detail: '握手超时' }); });
74
+ });
75
+ }
76
+
77
+ function httpGet(url: string, timeout = 10000): Promise<{ code: number; body: string; location?: string }> {
78
+ return new Promise((resolve, reject) => {
79
+ const req = https.get(url, { headers: { 'User-Agent': 'optima-verify-health' }, timeout }, (res) => {
80
+ let body = '';
81
+ res.on('data', (c) => (body += c));
82
+ res.on('end', () => resolve({ code: res.statusCode || 0, body, location: res.headers.location }));
83
+ });
84
+ req.on('error', reject);
85
+ req.on('timeout', () => { req.destroy(new Error('请求超时')); });
86
+ });
87
+ }
88
+
89
+ async function probe(host: string, path: string, expect?: string): Promise<Layer[]> {
90
+ const layers: Layer[] = [];
91
+
92
+ // L1 DNS
93
+ try {
94
+ const addrs = await dns.lookup(host, { all: true });
95
+ const ips = [...new Set(addrs.map((a) => a.address))];
96
+ layers.push({ layer: 'L1 DNS', result: 'ok', detail: `${host} → ${ips.join(', ')}` });
97
+ } catch (e: any) {
98
+ layers.push({ layer: 'L1 DNS', result: 'fail', detail: `${host} 无法解析: ${e.message}` });
99
+ return layers;
100
+ }
101
+
102
+ // L2 TLS
103
+ const t = await tlsCheck(host);
104
+ layers.push({ layer: 'L2 TLS', result: t.result, detail: t.detail });
105
+ if (!t.ok) return layers;
106
+
107
+ // L3-L5 /health
108
+ const url = `https://${host}${path}`;
109
+ let r: { code: number; body: string; location?: string };
110
+ try {
111
+ r = await httpGet(url);
112
+ } catch (e: any) {
113
+ layers.push({ layer: 'L3 /health', result: 'fail', detail: `${url} 无法连接: ${e.message}` });
114
+ return layers;
115
+ }
116
+ if (r.code >= 300 && r.code < 400) {
117
+ layers.push({ layer: 'L3 /health', result: 'fail', detail: `HTTP ${r.code} 重定向到 ${r.location || '?'}(没路由到服务,疑似未部署/listener 缺失)` });
118
+ return layers;
119
+ }
120
+ let d: any;
121
+ try { d = JSON.parse(r.body || '{}'); } catch {
122
+ layers.push({ layer: 'L3 /health', result: 'fail', detail: `HTTP ${r.code} 但非 JSON(可能不是 optima-core /health): ${JSON.stringify(r.body.slice(0, 80))}` });
123
+ return layers;
124
+ }
125
+
126
+ const status = d.status ?? '?';
127
+ if (status === 'healthy') layers.push({ layer: 'L3 /health', result: 'ok', detail: `HTTP ${r.code} status=healthy svc=${d.service ?? '?'} ver=${d.version ?? '?'}` });
128
+ else if (['ok', 'up', 'pass'].includes(status)) layers.push({ layer: 'L3 /health', result: 'warn', detail: `HTTP ${r.code} status=${status}(存活,但非 optima-core 标准 health,L4/L5 无从验证)` });
129
+ else if (status === 'degraded') layers.push({ layer: 'L3 /health', result: 'warn', detail: `HTTP ${r.code} status=degraded(Py:部分依赖挂但服务起着,见 L5)` });
130
+ else layers.push({ layer: 'L3 /health', result: 'fail', detail: `HTTP ${r.code} status=${status}` });
131
+
132
+ // L4 真部署 / 非裸起
133
+ const commit: string = d.gitCommit || d.git_commit || '';
134
+ const branch: string = d.gitBranch || '';
135
+ const bt = `commit=${commit || '∅'}` + (branch ? ` branch=${branch}` : '');
136
+ if (!commit) layers.push({ layer: 'L4 部署', result: 'warn', detail: '无 gitCommit(没用 optima-core health,或 build-info 没烘进去)' });
137
+ else if (expect) {
138
+ const short = expect.slice(0, commit.length);
139
+ layers.push(commit === short
140
+ ? { layer: 'L4 部署', result: 'ok', detail: `${bt} == 期望 ${short} ✓ 跑的是这次镜像` }
141
+ : { layer: 'L4 部署', result: 'fail', detail: `${bt} != 期望 ${short} ✗ 旧镜像/部署没生效` });
142
+ } else layers.push({ layer: 'L4 部署', result: 'ok', detail: `${bt}(未传 --expect-commit,仅展示)` });
143
+
144
+ // L5 依赖 checks 全绿
145
+ const checks: Record<string, any> = d.checks || {};
146
+ const keys = Object.keys(checks);
147
+ if (keys.length === 0) layers.push({ layer: 'L5 依赖', result: 'warn', detail: 'health 未注册任何 checks(服务没在 handler 里挂 DB/Redis/上游)' });
148
+ else {
149
+ const bad = keys.filter((k) => checks[k].status !== 'healthy');
150
+ const summary = keys.map((k) => `${k}=${checks[k].status}(${checks[k].latencyMs ?? checks[k].latency_ms ?? '?'}ms)`).join(', ');
151
+ layers.push(bad.length
152
+ ? { layer: 'L5 依赖', result: 'fail', detail: `${bad.length} 个不健康: ${bad.map((k) => `${k}=${checks[k].status}`).join(',')} | 全部: ${summary}` }
153
+ : { layer: 'L5 依赖', result: 'ok', detail: `${keys.length} 个依赖全绿: ${summary}` });
154
+ }
155
+ return layers;
156
+ }
157
+
158
+ function resolve(svc: string, e: Env): [string, string, string] | null {
159
+ const cfg = SERVICES[svc];
160
+ const host = cfg[e];
161
+ if (!host) return null;
162
+ const path = (cfg as any)[`${e}_path`] || cfg.path;
163
+ return [`${svc} [${e}]`, host, path];
164
+ }
165
+
166
+ async function main() {
167
+ const argv = process.argv.slice(2);
168
+ const has = (f: string) => argv.includes(f);
169
+ const val = (f: string) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
170
+ const asJson = has('--json'), strict = has('--strict');
171
+ const expect = val('--expect-commit');
172
+ const env = (val('--env') || 'cn') as Env | 'all';
173
+ const envs: Env[] = env === 'all' ? ENVS : [env as Env];
174
+ const positional = argv.filter((x, i) => !x.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--') && ['--env', '--expect-commit', '--url'].includes(argv[i - 1])));
175
+
176
+ let targets: [string, string, string][] = [];
177
+ if (has('--url')) {
178
+ const u = new URL(val('--url')!);
179
+ targets = [[u.hostname, u.hostname, u.pathname || '/health']];
180
+ } else if (has('--all')) {
181
+ for (const e of envs) for (const s of Object.keys(SERVICES)) { const t = resolve(s, e); if (t) targets.push(t); }
182
+ } else if (positional[0] && SERVICES[positional[0]]) {
183
+ for (const e of envs) { const t = resolve(positional[0], e); if (t) targets.push(t); }
184
+ } else {
185
+ console.log((require('fs').readFileSync(__filename, 'utf-8').match(/\/\*\*[\s\S]*?\*\//)?.[0] || '').replace(/^\s*\*?/gm, ''));
186
+ process.exit(2);
187
+ }
188
+ if (targets.length === 0) { console.log(`(无目标:${env} 环境下该服务无部署)`); process.exit(2); }
189
+
190
+ const results: any[] = [];
191
+ let worstOk = true;
192
+ for (const [name, host, path] of targets) {
193
+ const layers = await probe(host, path, expect);
194
+ const v = verdict(layers);
195
+ if (v === 'fail' || (strict && v === 'warn')) worstOk = false;
196
+ if (asJson) results.push({ service: name, url: `https://${host}${path}`, verdict: v, layers });
197
+ else {
198
+ console.log(`\n${'='.repeat(60)}\n ${name} https://${host}${path}\n${'='.repeat(60)}`);
199
+ for (const l of layers) console.log(` ${MARK[l.result] || '?'} ${l.layer.padEnd(11)} ${l.detail}`);
200
+ console.log(` ${'—'.repeat(56)}\n ${'结论'.padEnd(10)} ${VTXT[v]}`);
201
+ }
202
+ }
203
+ if (asJson) console.log(JSON.stringify(results, null, 2));
204
+ // 用 exitCode 而非 process.exit():后者会在管道(--json | jq)未 flush 完就退出而截断输出
205
+ process.exitCode = worstOk ? 0 : 1;
206
+ }
207
+
208
+ main();