@optima-chat/dev-skills 0.7.37 → 0.7.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/logs.md +55 -0
- package/.claude/skills/logs/SKILL.md +14 -1
- package/bin/cli.js +1 -0
- package/bin/helpers/db-utils.ts +78 -5
- package/bin/helpers/query-db.ts +2 -30
- package/bin/helpers/verify-health.ts +208 -0
- package/dist/bin/helpers/db-utils.js +86 -4
- package/dist/bin/helpers/query-db.js +2 -23
- package/dist/bin/helpers/verify-health.js +253 -0
- package/package.json +3 -2
package/.claude/commands/logs.md
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
- `ci` - CI 持续集成环境(开发环境,默认)
|
|
51
51
|
- `stage` - Stage 预发布环境(ECS Fargate)
|
|
52
52
|
- `prod` - 生产环境(ECS Fargate)
|
|
53
|
+
- `cn-prod`(别名 `cn`)- 阿里云 cn-prod 环境(SAE,cn-beijing,经 buildbox 取日志)
|
|
53
54
|
|
|
54
55
|
## 示例
|
|
55
56
|
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
- `ci` 或未指定 → 使用 SSH + Docker Compose(第 0 节,默认)
|
|
72
73
|
- `stage` → 使用 AWS CloudWatch Logs - ECS(第 1 节)
|
|
73
74
|
- `prod` → 使用 AWS CloudWatch Logs - ECS(第 2 节)
|
|
75
|
+
- `cn-prod` / `cn` → 阿里云 SAE,经 buildbox 调 `DescribeInstanceLog`(第 3 节)
|
|
74
76
|
|
|
75
77
|
### 0. CI 环境(environment = "ci" 或默认)
|
|
76
78
|
|
|
@@ -209,6 +211,59 @@ aws logs get-log-events --log-group-name /ecs/commerce-backend-prod --log-stream
|
|
|
209
211
|
|
|
210
212
|
**注意**: `optima-store` 仅在 Stage 环境部署
|
|
211
213
|
|
|
214
|
+
### 3. cn-prod 环境(environment = "cn-prod" 或 "cn")
|
|
215
|
+
|
|
216
|
+
**部署方式**: 阿里云 **SAE**(Serverless App Engine,cn-beijing),不是 ECS/CloudWatch。
|
|
217
|
+
**访问方式**: 经 buildbox(`root@47.94.105.163`,唯一装了 `aliyun-optima` profile + VPC 可达的机器)调 SAE OpenAPI。
|
|
218
|
+
|
|
219
|
+
> ⚠️ SAE **未配 SLS**,stdout 日志只能用 `DescribeInstanceLog` 取实例**当前缓冲**(非历史检索;重启/滚动后旧日志丢)。要历史检索需给 SAE 配 SLS。
|
|
220
|
+
|
|
221
|
+
**日志链**: service → AppId → GroupId → InstanceId → `DescribeInstanceLog`
|
|
222
|
+
|
|
223
|
+
**关键坑**(实测):
|
|
224
|
+
- API 是 RPC-style `aliyun sae DescribeInstanceLog`,ROA 路径是 `/pop/v1/sam/**instance**/describeInstanceLog`(不是 `/sam/app/...`,写错报 "can not find api by path")。
|
|
225
|
+
- buildbox 密码在 1P `op://Optima/gdmixcizjci5bvuu3nuvgg4ooe/password`(标题带括号必须用 item ID)。
|
|
226
|
+
|
|
227
|
+
**service → AppId 映射**(cn-prod,2026-06-01;新增/变动用 `aliyun sae ListApplications` 重查):
|
|
228
|
+
|
|
229
|
+
| service | AppId |
|
|
230
|
+
|---|---|
|
|
231
|
+
| `gateway-core` | `a08ce23f-3d3e-4d89-a2cf-53c8adba614e` |
|
|
232
|
+
| `agentic-chat` | `6e290c73-a646-43ef-9da5-ad0b2e7eff73` |
|
|
233
|
+
| `gw-admin` | `c6bc5a78-b27f-46e2-825a-eaa338c23645` |
|
|
234
|
+
| `user-auth` | `d6fbf9de-fed6-4165-978f-7b3a0a456acc` |
|
|
235
|
+
| `user-auth-admin` | `54093f99-37bf-4354-abd7-f6e76abdbcb6` |
|
|
236
|
+
| `optima-scout` | `f5bb7e82-e57c-4bd4-83ae-2c25f8d38647` |
|
|
237
|
+
| `optima-skills` | `55a63ee7-fb75-46e4-99f3-20854a699237` |
|
|
238
|
+
| `infisical` | `52b33fc6-b26d-495a-8885-225d38b3d42f` |
|
|
239
|
+
|
|
240
|
+
**完整脚本**(本地跑、经 buildbox 中转;改 `SERVICE`/`LINES`):
|
|
241
|
+
```bash
|
|
242
|
+
SERVICE=gateway-core; LINES=80
|
|
243
|
+
BBPW=$(op-win read "op://Optima/gdmixcizjci5bvuu3nuvgg4ooe/password")
|
|
244
|
+
declare -A APPID=(
|
|
245
|
+
[gateway-core]=a08ce23f-3d3e-4d89-a2cf-53c8adba614e
|
|
246
|
+
[agentic-chat]=6e290c73-a646-43ef-9da5-ad0b2e7eff73
|
|
247
|
+
[gw-admin]=c6bc5a78-b27f-46e2-825a-eaa338c23645
|
|
248
|
+
[user-auth]=d6fbf9de-fed6-4165-978f-7b3a0a456acc
|
|
249
|
+
[user-auth-admin]=54093f99-37bf-4354-abd7-f6e76abdbcb6
|
|
250
|
+
[optima-scout]=f5bb7e82-e57c-4bd4-83ae-2c25f8d38647
|
|
251
|
+
[optima-skills]=55a63ee7-fb75-46e4-99f3-20854a699237
|
|
252
|
+
[infisical]=52b33fc6-b26d-495a-8885-225d38b3d42f )
|
|
253
|
+
AID=${APPID[$SERVICE]}
|
|
254
|
+
sshpass -p "$BBPW" ssh -o StrictHostKeyChecking=no root@47.94.105.163 "bash -s" <<REMOTE | tail -n $LINES
|
|
255
|
+
P="--region cn-beijing --profile aliyun-optima"
|
|
256
|
+
GID=\$(aliyun sae GET /pop/v1/sam/app/describeApplicationGroups --AppId $AID \$P 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin)['Data'][0]['GroupId'])")
|
|
257
|
+
IID=\$(aliyun sae GET /pop/v1/sam/app/describeApplicationInstances --AppId $AID --GroupId \$GID \$P 2>/dev/null | python3 -c "import sys,json;d=json.load(sys.stdin)['Data'];print((d if isinstance(d,list) else d['Instances'])[0]['InstanceId'])")
|
|
258
|
+
aliyun sae DescribeInstanceLog --InstanceId "\$IID" \$P 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin).get('Data',''))"
|
|
259
|
+
REMOTE
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
**局限 / 提示**:
|
|
263
|
+
- 只取第 1 个 InstanceId;多副本要遍历 `Data` 里全部 instance。
|
|
264
|
+
- `agent-runtime` 不在 SAE(是 ECI 动态容器)——它的日志看 `gateway-core` 里 `eci-bridge` 的转发/错误行,或单独查对应 ECI。
|
|
265
|
+
- 过滤:把上面输出 `| grep -iE 'error|eci|fail'` 即可(例:排 agentic-chat 链路 ECI 失败就 grep `gateway-core` 日志的 `eci-bridge`)。
|
|
266
|
+
|
|
212
267
|
## 完整示例脚本
|
|
213
268
|
|
|
214
269
|
### Stage 环境
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: "logs"
|
|
3
|
-
description: "当用户请求查看日志、查看服务日志、排查问题、看看日志、检查日志、商品服务日志、后端日志、API日志、正式环境日志、生产环境日志、CI
|
|
3
|
+
description: "当用户请求查看日志、查看服务日志、排查问题、看看日志、检查日志、商品服务日志、后端日志、API日志、正式环境日志、生产环境日志、CI环境日志、开发环境日志、阿里云日志、cn-prod 日志、SAE 日志时,使用此技能。支持 CI、Stage、Prod(AWS ECS/CloudWatch)以及 cn-prod(阿里云 SAE,经 buildbox)四个环境的 commerce-backend、user-auth、agentic-chat、gateway-core、optima-scout、optima-skills 等服务。"
|
|
4
4
|
allowed-tools: ["Bash", "SlashCommand"]
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -175,6 +175,19 @@ INFO - Database query took 3200ms: SELECT * FROM products WHERE...
|
|
|
175
175
|
- 通过 CloudWatch Logs 查看
|
|
176
176
|
- 日志保留 7 天
|
|
177
177
|
|
|
178
|
+
### cn-prod 环境(阿里云)
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
/logs gateway-core 100 cn-prod
|
|
182
|
+
/logs agentic-chat 80 cn
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**特点**:
|
|
186
|
+
- 阿里云 **SAE**(cn-beijing),不是 AWS
|
|
187
|
+
- 经 buildbox(`root@47.94.105.163`)调 SAE `DescribeInstanceLog`
|
|
188
|
+
- SAE 未配 SLS → 只取实例**当前 stdout 缓冲**(非历史检索)
|
|
189
|
+
- 技术细节 + service→AppId 映射见 `/logs --help`(第 3 节)
|
|
190
|
+
|
|
178
191
|
## 支持的服务列表
|
|
179
192
|
|
|
180
193
|
| 服务 | 说明 | CI | Stage | Prod |
|
package/bin/cli.js
CHANGED
|
@@ -32,6 +32,7 @@ switch (command) {
|
|
|
32
32
|
log('Available Commands:', 'yellow');
|
|
33
33
|
log(' optima-query-db <service> "<sql>" [env] Query database', 'cyan');
|
|
34
34
|
log(' optima-show-env <service> [env] Show service env vars', 'cyan');
|
|
35
|
+
log(' optima-verify-health <service> [--env cn|prod|all] Probe L1-L5 上线健康', 'cyan');
|
|
35
36
|
log(' optima-generate-test-token [--env production] Generate test token', 'cyan');
|
|
36
37
|
log(' optima-grant-balance <email> --amount <usd> [--env] Grant USD wallet balance', 'cyan');
|
|
37
38
|
log(' optima-grant-subscription <email> --plan <p> [--env] Grant subscription', 'cyan');
|
package/bin/helpers/db-utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { execSync } from 'child_process';
|
|
1
|
+
import { execSync, spawn } from 'child_process';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
3
4
|
|
|
4
5
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
5
6
|
export interface InfisicalConfig { url: string; clientId: string; clientSecret: string; projectId: string }
|
|
@@ -18,6 +19,11 @@ export const RDS_HOSTS: Record<string, string> = {
|
|
|
18
19
|
prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
|
|
19
20
|
};
|
|
20
21
|
|
|
22
|
+
// Shared bastion (optima-bi-data EC2, shared-services stack). Reached via SSM —
|
|
23
|
+
// no public port 22 dependency, so it is immune to the MaxStartups throttling that
|
|
24
|
+
// the open-to-world sshd suffers under internet scan load. SSH path kept as fallback.
|
|
25
|
+
const AWS_REGION = 'ap-southeast-1';
|
|
26
|
+
const BASTION_INSTANCE_ID = 'i-03286fb0a9ce7e6b1';
|
|
21
27
|
const EC2_HOST = '3.0.210.113';
|
|
22
28
|
|
|
23
29
|
// ─── SQL escaping ───────────────────────────────────────────────────────────
|
|
@@ -114,6 +120,75 @@ export function setupSSHTunnel(dbHost: string, localPort: number): void {
|
|
|
114
120
|
);
|
|
115
121
|
}
|
|
116
122
|
|
|
123
|
+
/** Block the current (sync) call for `ms` without spawning a subprocess. */
|
|
124
|
+
function sleepSync(ms: number): void {
|
|
125
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertSSMPrereqs(): void {
|
|
129
|
+
for (const [bin, hint] of [
|
|
130
|
+
['session-manager-plugin', 'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html'],
|
|
131
|
+
['aws', 'AWS CLI v2: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html'],
|
|
132
|
+
] as const) {
|
|
133
|
+
try { execSync(`command -v ${bin}`, { stdio: 'ignore' }); }
|
|
134
|
+
catch { 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)`); }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Open a local→RDS tunnel through the shared bastion using SSM port forwarding.
|
|
140
|
+
* No public SSH port is touched. A healthy existing tunnel on the port is reused
|
|
141
|
+
* (the ~2s SSM cold start is paid once, then every query is just a round-trip).
|
|
142
|
+
*/
|
|
143
|
+
export function setupSSMTunnel(dbHost: string, localPort: number): void {
|
|
144
|
+
let portInUse = false;
|
|
145
|
+
try { execSync(`lsof -ti:${localPort}`, { stdio: 'ignore' }); portInUse = true; } catch { /* free */ }
|
|
146
|
+
|
|
147
|
+
if (portInUse) {
|
|
148
|
+
if (isTunnelHealthy(localPort)) return; // warm reuse — no cold start
|
|
149
|
+
console.log(`! Tunnel on port ${localPort} not responding (zombie), replacing...`);
|
|
150
|
+
killOrphanTunnel(localPort);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
assertSSMPrereqs();
|
|
154
|
+
console.log(`Creating SSM tunnel: localhost:${localPort} -> ${BASTION_INSTANCE_ID} -> ${dbHost}:5432`);
|
|
155
|
+
|
|
156
|
+
const logFile = `${os.tmpdir()}/optima-ssm-tunnel-${localPort}.log`;
|
|
157
|
+
const out = fs.openSync(logFile, 'w');
|
|
158
|
+
const child = spawn('aws', [
|
|
159
|
+
'ssm', 'start-session',
|
|
160
|
+
'--region', AWS_REGION,
|
|
161
|
+
'--target', BASTION_INSTANCE_ID,
|
|
162
|
+
'--document-name', 'AWS-StartPortForwardingSessionToRemoteHost',
|
|
163
|
+
'--parameters', `host=${dbHost},portNumber=5432,localPortNumber=${localPort}`,
|
|
164
|
+
], { detached: true, stdio: ['ignore', out, out] });
|
|
165
|
+
child.unref();
|
|
166
|
+
|
|
167
|
+
// Wait until the remote leg actually forwards (pg_isready probes RDS through the
|
|
168
|
+
// tunnel). Local port binds instantly; the remote hop needs ~1-2s to come up.
|
|
169
|
+
const deadlineMs = Date.now() + 20000;
|
|
170
|
+
while (Date.now() < deadlineMs) {
|
|
171
|
+
if (isTunnelHealthy(localPort)) { console.log(`✓ SSM tunnel ready on port ${localPort}`); return; }
|
|
172
|
+
sleepSync(500);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let tail = '(no log)';
|
|
176
|
+
try { tail = fs.readFileSync(logFile, 'utf-8').split('\n').slice(-8).join('\n'); } catch { /* ignore */ }
|
|
177
|
+
throw new Error(`SSM tunnel on port ${localPort} did not become ready within 20s.\n--- ${logFile} ---\n${tail}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Open a DB tunnel through the shared bastion. Defaults to SSM (no public port 22);
|
|
182
|
+
* set OPTIMA_DB_TUNNEL=ssh to fall back to the legacy SSH tunnel.
|
|
183
|
+
*/
|
|
184
|
+
export function setupTunnel(dbHost: string, localPort: number): void {
|
|
185
|
+
if ((process.env.OPTIMA_DB_TUNNEL || 'ssm').toLowerCase() === 'ssh') {
|
|
186
|
+
setupSSHTunnel(dbHost, localPort);
|
|
187
|
+
} else {
|
|
188
|
+
setupSSMTunnel(dbHost, localPort);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
117
192
|
// ─── psql ───────────────────────────────────────────────────────────────────
|
|
118
193
|
function findPsqlPath(): string {
|
|
119
194
|
try {
|
|
@@ -142,8 +217,7 @@ export async function connectAuthDB(env: string, infisicalConfig: InfisicalConfi
|
|
|
142
217
|
const host = RDS_HOSTS[env as 'stage' | 'prod'];
|
|
143
218
|
const port = env === 'stage' ? 15432 : 15433;
|
|
144
219
|
|
|
145
|
-
|
|
146
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
220
|
+
setupTunnel(host, port);
|
|
147
221
|
|
|
148
222
|
const conn: DBConnection = { host: 'localhost', port, user: secrets['AUTH_DB_USER'], password: secrets['AUTH_DB_PASSWORD'], database: 'optima_auth' };
|
|
149
223
|
return { query: (sql: string) => queryDB(conn, sql) };
|
|
@@ -158,8 +232,7 @@ export async function connectBillingDB(env: string, infisicalConfig: InfisicalCo
|
|
|
158
232
|
|
|
159
233
|
const parsed = parseDatabaseUrl(dbUrl);
|
|
160
234
|
const port = env === 'stage' ? 15434 : 15435;
|
|
161
|
-
|
|
162
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
235
|
+
setupTunnel(parsed.host, port);
|
|
163
236
|
|
|
164
237
|
const conn: DBConnection = { host: 'localhost', port, user: parsed.user, password: parsed.password, database: parsed.database };
|
|
165
238
|
return { query: (sql: string) => queryDB(conn, sql) };
|
package/bin/helpers/query-db.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
4
|
import * as fs from 'fs';
|
|
5
|
+
import { setupTunnel } from './db-utils';
|
|
5
6
|
|
|
6
7
|
interface InfisicalConfig {
|
|
7
8
|
url: string;
|
|
@@ -96,9 +97,6 @@ const RDS_HOSTS = {
|
|
|
96
97
|
prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com'
|
|
97
98
|
};
|
|
98
99
|
|
|
99
|
-
// 统一使用 BI Data ARM Host 作为跳板机
|
|
100
|
-
const EC2_HOST = '3.0.210.113';
|
|
101
|
-
|
|
102
100
|
function parseDatabaseUrl(url: string): { user: string; password: string; host: string; port: number; database: string } {
|
|
103
101
|
// postgresql://user:password@host:port/database?params
|
|
104
102
|
const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
|
|
@@ -148,29 +146,6 @@ function getInfisicalSecrets(config: InfisicalConfig, token: string, environment
|
|
|
148
146
|
return secrets;
|
|
149
147
|
}
|
|
150
148
|
|
|
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
|
-
}
|
|
173
|
-
|
|
174
149
|
function findPsqlPath(): string {
|
|
175
150
|
// 1. 优先从 PATH 中查找
|
|
176
151
|
const whichCmd = process.platform === 'win32' ? 'where psql' : 'which psql';
|
|
@@ -323,10 +298,7 @@ async function main() {
|
|
|
323
298
|
|
|
324
299
|
const localPort = environment === 'stage' ? 15432 : 15433;
|
|
325
300
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
// 等待隧道建立
|
|
329
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
301
|
+
setupTunnel(dbHost, localPort);
|
|
330
302
|
|
|
331
303
|
const result = queryDatabase('localhost', localPort, dbUser, dbPassword, database, sql);
|
|
332
304
|
console.log('\n' + result);
|
|
@@ -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();
|
|
@@ -41,17 +41,25 @@ exports.getInfisicalToken = getInfisicalToken;
|
|
|
41
41
|
exports.getInfisicalSecrets = getInfisicalSecrets;
|
|
42
42
|
exports.parseDatabaseUrl = parseDatabaseUrl;
|
|
43
43
|
exports.setupSSHTunnel = setupSSHTunnel;
|
|
44
|
+
exports.setupSSMTunnel = setupSSMTunnel;
|
|
45
|
+
exports.setupTunnel = setupTunnel;
|
|
44
46
|
exports.queryDB = queryDB;
|
|
45
47
|
exports.connectAuthDB = connectAuthDB;
|
|
46
48
|
exports.connectBillingDB = connectBillingDB;
|
|
47
49
|
exports.resolveUserId = resolveUserId;
|
|
48
50
|
const child_process_1 = require("child_process");
|
|
49
51
|
const fs = __importStar(require("fs"));
|
|
52
|
+
const os = __importStar(require("os"));
|
|
50
53
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
51
54
|
exports.RDS_HOSTS = {
|
|
52
55
|
stage: 'optima-stage-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
|
|
53
56
|
prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
|
|
54
57
|
};
|
|
58
|
+
// Shared bastion (optima-bi-data EC2, shared-services stack). Reached via SSM —
|
|
59
|
+
// no public port 22 dependency, so it is immune to the MaxStartups throttling that
|
|
60
|
+
// the open-to-world sshd suffers under internet scan load. SSH path kept as fallback.
|
|
61
|
+
const AWS_REGION = 'ap-southeast-1';
|
|
62
|
+
const BASTION_INSTANCE_ID = 'i-03286fb0a9ce7e6b1';
|
|
55
63
|
const EC2_HOST = '3.0.210.113';
|
|
56
64
|
// ─── SQL escaping ───────────────────────────────────────────────────────────
|
|
57
65
|
/** Escape a string value for safe inclusion in SQL single-quoted literals. */
|
|
@@ -137,6 +145,82 @@ function setupSSHTunnel(dbHost, localPort) {
|
|
|
137
145
|
`-o ExitOnForwardFailure=yes -o ConnectTimeout=10 ` +
|
|
138
146
|
`-L ${localPort}:${dbHost}:5432 ec2-user@${EC2_HOST}`, { stdio: 'inherit' });
|
|
139
147
|
}
|
|
148
|
+
/** Block the current (sync) call for `ms` without spawning a subprocess. */
|
|
149
|
+
function sleepSync(ms) {
|
|
150
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
151
|
+
}
|
|
152
|
+
function assertSSMPrereqs() {
|
|
153
|
+
for (const [bin, hint] of [
|
|
154
|
+
['session-manager-plugin', 'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html'],
|
|
155
|
+
['aws', 'AWS CLI v2: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html'],
|
|
156
|
+
]) {
|
|
157
|
+
try {
|
|
158
|
+
(0, child_process_1.execSync)(`command -v ${bin}`, { stdio: 'ignore' });
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
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)`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Open a local→RDS tunnel through the shared bastion using SSM port forwarding.
|
|
167
|
+
* No public SSH port is touched. A healthy existing tunnel on the port is reused
|
|
168
|
+
* (the ~2s SSM cold start is paid once, then every query is just a round-trip).
|
|
169
|
+
*/
|
|
170
|
+
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
|
+
assertSSMPrereqs();
|
|
184
|
+
console.log(`Creating SSM tunnel: localhost:${localPort} -> ${BASTION_INSTANCE_ID} -> ${dbHost}:5432`);
|
|
185
|
+
const logFile = `${os.tmpdir()}/optima-ssm-tunnel-${localPort}.log`;
|
|
186
|
+
const out = fs.openSync(logFile, 'w');
|
|
187
|
+
const child = (0, child_process_1.spawn)('aws', [
|
|
188
|
+
'ssm', 'start-session',
|
|
189
|
+
'--region', AWS_REGION,
|
|
190
|
+
'--target', BASTION_INSTANCE_ID,
|
|
191
|
+
'--document-name', 'AWS-StartPortForwardingSessionToRemoteHost',
|
|
192
|
+
'--parameters', `host=${dbHost},portNumber=5432,localPortNumber=${localPort}`,
|
|
193
|
+
], { detached: true, stdio: ['ignore', out, out] });
|
|
194
|
+
child.unref();
|
|
195
|
+
// Wait until the remote leg actually forwards (pg_isready probes RDS through the
|
|
196
|
+
// tunnel). Local port binds instantly; the remote hop needs ~1-2s to come up.
|
|
197
|
+
const deadlineMs = Date.now() + 20000;
|
|
198
|
+
while (Date.now() < deadlineMs) {
|
|
199
|
+
if (isTunnelHealthy(localPort)) {
|
|
200
|
+
console.log(`✓ SSM tunnel ready on port ${localPort}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
sleepSync(500);
|
|
204
|
+
}
|
|
205
|
+
let tail = '(no log)';
|
|
206
|
+
try {
|
|
207
|
+
tail = fs.readFileSync(logFile, 'utf-8').split('\n').slice(-8).join('\n');
|
|
208
|
+
}
|
|
209
|
+
catch { /* ignore */ }
|
|
210
|
+
throw new Error(`SSM tunnel on port ${localPort} did not become ready within 20s.\n--- ${logFile} ---\n${tail}`);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
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.
|
|
215
|
+
*/
|
|
216
|
+
function setupTunnel(dbHost, localPort) {
|
|
217
|
+
if ((process.env.OPTIMA_DB_TUNNEL || 'ssm').toLowerCase() === 'ssh') {
|
|
218
|
+
setupSSHTunnel(dbHost, localPort);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
setupSSMTunnel(dbHost, localPort);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
140
224
|
// ─── psql ───────────────────────────────────────────────────────────────────
|
|
141
225
|
function findPsqlPath() {
|
|
142
226
|
try {
|
|
@@ -166,8 +250,7 @@ async function connectAuthDB(env, infisicalConfig, token) {
|
|
|
166
250
|
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/shared-secrets/database-users');
|
|
167
251
|
const host = exports.RDS_HOSTS[env];
|
|
168
252
|
const port = env === 'stage' ? 15432 : 15433;
|
|
169
|
-
|
|
170
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
253
|
+
setupTunnel(host, port);
|
|
171
254
|
const conn = { host: 'localhost', port, user: secrets['AUTH_DB_USER'], password: secrets['AUTH_DB_PASSWORD'], database: 'optima_auth' };
|
|
172
255
|
return { query: (sql) => queryDB(conn, sql) };
|
|
173
256
|
}
|
|
@@ -180,8 +263,7 @@ async function connectBillingDB(env, infisicalConfig, token) {
|
|
|
180
263
|
throw new Error('DATABASE_URL not found for billing service');
|
|
181
264
|
const parsed = parseDatabaseUrl(dbUrl);
|
|
182
265
|
const port = env === 'stage' ? 15434 : 15435;
|
|
183
|
-
|
|
184
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
266
|
+
setupTunnel(parsed.host, port);
|
|
185
267
|
const conn = { host: 'localhost', port, user: parsed.user, password: parsed.password, database: parsed.database };
|
|
186
268
|
return { query: (sql) => queryDB(conn, sql) };
|
|
187
269
|
}
|
|
@@ -36,6 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
37
|
const child_process_1 = require("child_process");
|
|
38
38
|
const fs = __importStar(require("fs"));
|
|
39
|
+
const db_utils_1 = require("./db-utils");
|
|
39
40
|
const SERVICE_DB_MAP = {
|
|
40
41
|
'commerce-backend': {
|
|
41
42
|
ci: { container: 'commerce-postgres', user: 'commerce', password: 'commerce123', database: 'commerce' },
|
|
@@ -113,8 +114,6 @@ const RDS_HOSTS = {
|
|
|
113
114
|
stage: 'optima-stage-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
|
|
114
115
|
prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com'
|
|
115
116
|
};
|
|
116
|
-
// 统一使用 BI Data ARM Host 作为跳板机
|
|
117
|
-
const EC2_HOST = '3.0.210.113';
|
|
118
117
|
function parseDatabaseUrl(url) {
|
|
119
118
|
// postgresql://user:password@host:port/database?params
|
|
120
119
|
const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
|
|
@@ -153,24 +152,6 @@ function getInfisicalSecrets(config, token, environment, secretPath) {
|
|
|
153
152
|
}
|
|
154
153
|
return secrets;
|
|
155
154
|
}
|
|
156
|
-
function setupSSHTunnel(ec2Host, dbHost, localPort) {
|
|
157
|
-
// 检查是否已有隧道
|
|
158
|
-
try {
|
|
159
|
-
(0, child_process_1.execSync)(`lsof -ti:${localPort}`, { stdio: 'ignore' });
|
|
160
|
-
console.log(`✓ SSH tunnel already exists on port ${localPort}`);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
// 端口未占用,创建隧道
|
|
165
|
-
}
|
|
166
|
-
const sshKeyPath = `${process.env.HOME}/.ssh/optima-ec2-key`;
|
|
167
|
-
if (!fs.existsSync(sshKeyPath)) {
|
|
168
|
-
throw new Error(`SSH key not found: ${sshKeyPath}. Please obtain optima-ec2-key from xbfool.`);
|
|
169
|
-
}
|
|
170
|
-
console.log(`Creating SSH tunnel: localhost:${localPort} -> ${ec2Host} -> ${dbHost}:5432`);
|
|
171
|
-
(0, child_process_1.execSync)(`ssh -i ${sshKeyPath} -f -N -o StrictHostKeyChecking=no -L ${localPort}:${dbHost}:5432 ec2-user@${ec2Host}`, { stdio: 'inherit' });
|
|
172
|
-
console.log(`✓ SSH tunnel established on port ${localPort}`);
|
|
173
|
-
}
|
|
174
155
|
function findPsqlPath() {
|
|
175
156
|
// 1. 优先从 PATH 中查找
|
|
176
157
|
const whichCmd = process.platform === 'win32' ? 'where psql' : 'which psql';
|
|
@@ -294,9 +275,7 @@ async function main() {
|
|
|
294
275
|
}
|
|
295
276
|
}
|
|
296
277
|
const localPort = environment === 'stage' ? 15432 : 15433;
|
|
297
|
-
|
|
298
|
-
// 等待隧道建立
|
|
299
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
278
|
+
(0, db_utils_1.setupTunnel)(dbHost, localPort);
|
|
300
279
|
const result = queryDatabase('localhost', localPort, dbUser, dbPassword, database, sql);
|
|
301
280
|
console.log('\n' + result);
|
|
302
281
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
/**
|
|
38
|
+
* optima-verify-health —— 服务上线健康探针(L1 DNS / L2 TLS / L3-L5 /health)。
|
|
39
|
+
*
|
|
40
|
+
* 逐层探、逐层报,逐层 pass 才算上线成功。零依赖(纯 Node 内置)。
|
|
41
|
+
* 配套静态合规审计 audit-probe.py 在 optima-terraform 仓 docs/cn-prod/probes/(读 terraform 文件,绑仓不搬)。
|
|
42
|
+
*
|
|
43
|
+
* L1 DNS : FQDN 能解析到 IP
|
|
44
|
+
* L2 TLS : 443 握手 + 证书链/有效期/SNI(SAN)匹配
|
|
45
|
+
* L3 /health : optima-core 风格 /health,顶层 status 健康
|
|
46
|
+
* L4 真部署 : 解析 gitCommit;传 --expect-commit <sha> 时比对(证明跑的是这次镜像)
|
|
47
|
+
* L5 依赖 : checks[*] 全绿(DB/Redis/上游由服务在 health handler 自注册)
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ optima-core 的 JS 与 Py 两套 /health schema 不一致,都兼容:
|
|
50
|
+
* JS : gitCommit(camel) / status∈{healthy,unhealthy} / 不健康返 503 / 有 gitBranch
|
|
51
|
+
* Py : git_commit(snake) / status∈{healthy,degraded} / 永远 200 / check 可 timeout|error / 无 git_branch
|
|
52
|
+
*
|
|
53
|
+
* 用法:
|
|
54
|
+
* optima-verify-health user-auth # 默认 cn-prod
|
|
55
|
+
* optima-verify-health user-auth --env prod # 环境 stage|prod|cn|all
|
|
56
|
+
* optima-verify-health --all --env all # stage/prod/cn 三环境矩阵
|
|
57
|
+
* optima-verify-health gateway-core --expect-commit a1b2c3d
|
|
58
|
+
* optima-verify-health --url https://auth-cn.optima.chat/health
|
|
59
|
+
* optima-verify-health --all --json # 机器可读,接 CI
|
|
60
|
+
* optima-verify-health --all --strict # warn 也算不通过
|
|
61
|
+
* 退出码:fail → 非 0;warn 默认放行(0),--strict 让 warn 也非 0。
|
|
62
|
+
*/
|
|
63
|
+
const node_dns_1 = require("node:dns");
|
|
64
|
+
const tls = __importStar(require("node:tls"));
|
|
65
|
+
const https = __importStar(require("node:https"));
|
|
66
|
+
// 服务 × 环境 FQDN 表。某服务某环境没部署 → 该 env 键缺省,探时跳过。
|
|
67
|
+
// cn-prod 真实 subdomain 抄自 optima-terraform 的 stack main.tf(非文档表,如 agentic-chat 真名是 agentic-chat-cn 不是 ai-cn)。
|
|
68
|
+
const SERVICES = {
|
|
69
|
+
'user-auth': { path: '/health', stage: 'auth-stage.optima.onl', prod: 'auth.optima.onl', cn: 'auth-cn.optima.chat' },
|
|
70
|
+
'agentic-chat': { path: '/api/health', stage: 'ai-stage.optima.onl', prod: 'ai.optima.onl', cn: 'agentic-chat-cn.optima.chat' },
|
|
71
|
+
'commerce-backend': { path: '/health', stage: 'api-stage.optima.onl', prod: 'api.optima.onl', cn: 'api-cn.optima.chat', cn_path: '/health/live' },
|
|
72
|
+
'mcp-host': { path: '/health', stage: 'mcp-stage.optima.onl', prod: 'mcp.optima.onl' },
|
|
73
|
+
'gateway-core': { path: '/health', cn: 'gw-cn.optima.chat' },
|
|
74
|
+
'optima-scout': { path: '/health', cn: 'scout-cn.optima.chat' },
|
|
75
|
+
'optima-skills': { path: '/health', cn: 'skills-cn.optima.chat' },
|
|
76
|
+
};
|
|
77
|
+
const ENVS = ['stage', 'prod', 'cn'];
|
|
78
|
+
const G = '\x1b[32m', R = '\x1b[31m', Y = '\x1b[33m', B = '\x1b[34m', N = '\x1b[0m';
|
|
79
|
+
const MARK = { ok: `${G}✅${N}`, fail: `${R}❌${N}`, warn: `${Y}⚠️ ${N}`, na: `${B}··${N}` };
|
|
80
|
+
const verdict = (ls) => ls.some((l) => l.result === 'fail') ? 'fail' : ls.some((l) => l.result === 'warn') ? 'warn' : 'pass';
|
|
81
|
+
const VTXT = {
|
|
82
|
+
pass: `${G}上线成功(L1-L5 全绿)${N}`,
|
|
83
|
+
warn: `${Y}存活但有告警(见上 ⚠️;非阻塞)${N}`,
|
|
84
|
+
fail: `${R}未通过 — 见上 ❌${N}`,
|
|
85
|
+
};
|
|
86
|
+
function tlsCheck(host, timeout = 10000) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const sock = tls.connect({ host, port: 443, servername: host, timeout }, () => {
|
|
89
|
+
const cert = sock.getPeerCertificate();
|
|
90
|
+
const days = Math.floor((new Date(cert.valid_to).getTime() - Date.now()) / 86400000);
|
|
91
|
+
const sans = (cert.subjectaltname || '').split(',').map((s) => s.trim().replace(/^DNS:/, '')).slice(0, 3);
|
|
92
|
+
sock.end();
|
|
93
|
+
resolve({ ok: true, result: days > 7 ? 'ok' : 'warn',
|
|
94
|
+
detail: `证书 ${days}d 后过期, SAN=${sans.join(',')}` + (days > 7 ? '' : ` (剩 ${days}d!)`) });
|
|
95
|
+
});
|
|
96
|
+
sock.on('error', (e) => resolve({ ok: false, result: 'fail', detail: `握手/证书失败: ${e.message}` }));
|
|
97
|
+
sock.on('timeout', () => { sock.destroy(); resolve({ ok: false, result: 'fail', detail: '握手超时' }); });
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function httpGet(url, timeout = 10000) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const req = https.get(url, { headers: { 'User-Agent': 'optima-verify-health' }, timeout }, (res) => {
|
|
103
|
+
let body = '';
|
|
104
|
+
res.on('data', (c) => (body += c));
|
|
105
|
+
res.on('end', () => resolve({ code: res.statusCode || 0, body, location: res.headers.location }));
|
|
106
|
+
});
|
|
107
|
+
req.on('error', reject);
|
|
108
|
+
req.on('timeout', () => { req.destroy(new Error('请求超时')); });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async function probe(host, path, expect) {
|
|
112
|
+
const layers = [];
|
|
113
|
+
// L1 DNS
|
|
114
|
+
try {
|
|
115
|
+
const addrs = await node_dns_1.promises.lookup(host, { all: true });
|
|
116
|
+
const ips = [...new Set(addrs.map((a) => a.address))];
|
|
117
|
+
layers.push({ layer: 'L1 DNS', result: 'ok', detail: `${host} → ${ips.join(', ')}` });
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
layers.push({ layer: 'L1 DNS', result: 'fail', detail: `${host} 无法解析: ${e.message}` });
|
|
121
|
+
return layers;
|
|
122
|
+
}
|
|
123
|
+
// L2 TLS
|
|
124
|
+
const t = await tlsCheck(host);
|
|
125
|
+
layers.push({ layer: 'L2 TLS', result: t.result, detail: t.detail });
|
|
126
|
+
if (!t.ok)
|
|
127
|
+
return layers;
|
|
128
|
+
// L3-L5 /health
|
|
129
|
+
const url = `https://${host}${path}`;
|
|
130
|
+
let r;
|
|
131
|
+
try {
|
|
132
|
+
r = await httpGet(url);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
layers.push({ layer: 'L3 /health', result: 'fail', detail: `${url} 无法连接: ${e.message}` });
|
|
136
|
+
return layers;
|
|
137
|
+
}
|
|
138
|
+
if (r.code >= 300 && r.code < 400) {
|
|
139
|
+
layers.push({ layer: 'L3 /health', result: 'fail', detail: `HTTP ${r.code} 重定向到 ${r.location || '?'}(没路由到服务,疑似未部署/listener 缺失)` });
|
|
140
|
+
return layers;
|
|
141
|
+
}
|
|
142
|
+
let d;
|
|
143
|
+
try {
|
|
144
|
+
d = JSON.parse(r.body || '{}');
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
layers.push({ layer: 'L3 /health', result: 'fail', detail: `HTTP ${r.code} 但非 JSON(可能不是 optima-core /health): ${JSON.stringify(r.body.slice(0, 80))}` });
|
|
148
|
+
return layers;
|
|
149
|
+
}
|
|
150
|
+
const status = d.status ?? '?';
|
|
151
|
+
if (status === 'healthy')
|
|
152
|
+
layers.push({ layer: 'L3 /health', result: 'ok', detail: `HTTP ${r.code} status=healthy svc=${d.service ?? '?'} ver=${d.version ?? '?'}` });
|
|
153
|
+
else if (['ok', 'up', 'pass'].includes(status))
|
|
154
|
+
layers.push({ layer: 'L3 /health', result: 'warn', detail: `HTTP ${r.code} status=${status}(存活,但非 optima-core 标准 health,L4/L5 无从验证)` });
|
|
155
|
+
else if (status === 'degraded')
|
|
156
|
+
layers.push({ layer: 'L3 /health', result: 'warn', detail: `HTTP ${r.code} status=degraded(Py:部分依赖挂但服务起着,见 L5)` });
|
|
157
|
+
else
|
|
158
|
+
layers.push({ layer: 'L3 /health', result: 'fail', detail: `HTTP ${r.code} status=${status}` });
|
|
159
|
+
// L4 真部署 / 非裸起
|
|
160
|
+
const commit = d.gitCommit || d.git_commit || '';
|
|
161
|
+
const branch = d.gitBranch || '';
|
|
162
|
+
const bt = `commit=${commit || '∅'}` + (branch ? ` branch=${branch}` : '');
|
|
163
|
+
if (!commit)
|
|
164
|
+
layers.push({ layer: 'L4 部署', result: 'warn', detail: '无 gitCommit(没用 optima-core health,或 build-info 没烘进去)' });
|
|
165
|
+
else if (expect) {
|
|
166
|
+
const short = expect.slice(0, commit.length);
|
|
167
|
+
layers.push(commit === short
|
|
168
|
+
? { layer: 'L4 部署', result: 'ok', detail: `${bt} == 期望 ${short} ✓ 跑的是这次镜像` }
|
|
169
|
+
: { layer: 'L4 部署', result: 'fail', detail: `${bt} != 期望 ${short} ✗ 旧镜像/部署没生效` });
|
|
170
|
+
}
|
|
171
|
+
else
|
|
172
|
+
layers.push({ layer: 'L4 部署', result: 'ok', detail: `${bt}(未传 --expect-commit,仅展示)` });
|
|
173
|
+
// L5 依赖 checks 全绿
|
|
174
|
+
const checks = d.checks || {};
|
|
175
|
+
const keys = Object.keys(checks);
|
|
176
|
+
if (keys.length === 0)
|
|
177
|
+
layers.push({ layer: 'L5 依赖', result: 'warn', detail: 'health 未注册任何 checks(服务没在 handler 里挂 DB/Redis/上游)' });
|
|
178
|
+
else {
|
|
179
|
+
const bad = keys.filter((k) => checks[k].status !== 'healthy');
|
|
180
|
+
const summary = keys.map((k) => `${k}=${checks[k].status}(${checks[k].latencyMs ?? checks[k].latency_ms ?? '?'}ms)`).join(', ');
|
|
181
|
+
layers.push(bad.length
|
|
182
|
+
? { layer: 'L5 依赖', result: 'fail', detail: `${bad.length} 个不健康: ${bad.map((k) => `${k}=${checks[k].status}`).join(',')} | 全部: ${summary}` }
|
|
183
|
+
: { layer: 'L5 依赖', result: 'ok', detail: `${keys.length} 个依赖全绿: ${summary}` });
|
|
184
|
+
}
|
|
185
|
+
return layers;
|
|
186
|
+
}
|
|
187
|
+
function resolve(svc, e) {
|
|
188
|
+
const cfg = SERVICES[svc];
|
|
189
|
+
const host = cfg[e];
|
|
190
|
+
if (!host)
|
|
191
|
+
return null;
|
|
192
|
+
const path = cfg[`${e}_path`] || cfg.path;
|
|
193
|
+
return [`${svc} [${e}]`, host, path];
|
|
194
|
+
}
|
|
195
|
+
async function main() {
|
|
196
|
+
const argv = process.argv.slice(2);
|
|
197
|
+
const has = (f) => argv.includes(f);
|
|
198
|
+
const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
|
|
199
|
+
const asJson = has('--json'), strict = has('--strict');
|
|
200
|
+
const expect = val('--expect-commit');
|
|
201
|
+
const env = (val('--env') || 'cn');
|
|
202
|
+
const envs = env === 'all' ? ENVS : [env];
|
|
203
|
+
const positional = argv.filter((x, i) => !x.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--') && ['--env', '--expect-commit', '--url'].includes(argv[i - 1])));
|
|
204
|
+
let targets = [];
|
|
205
|
+
if (has('--url')) {
|
|
206
|
+
const u = new URL(val('--url'));
|
|
207
|
+
targets = [[u.hostname, u.hostname, u.pathname || '/health']];
|
|
208
|
+
}
|
|
209
|
+
else if (has('--all')) {
|
|
210
|
+
for (const e of envs)
|
|
211
|
+
for (const s of Object.keys(SERVICES)) {
|
|
212
|
+
const t = resolve(s, e);
|
|
213
|
+
if (t)
|
|
214
|
+
targets.push(t);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else if (positional[0] && SERVICES[positional[0]]) {
|
|
218
|
+
for (const e of envs) {
|
|
219
|
+
const t = resolve(positional[0], e);
|
|
220
|
+
if (t)
|
|
221
|
+
targets.push(t);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
console.log((require('fs').readFileSync(__filename, 'utf-8').match(/\/\*\*[\s\S]*?\*\//)?.[0] || '').replace(/^\s*\*?/gm, ''));
|
|
226
|
+
process.exit(2);
|
|
227
|
+
}
|
|
228
|
+
if (targets.length === 0) {
|
|
229
|
+
console.log(`(无目标:${env} 环境下该服务无部署)`);
|
|
230
|
+
process.exit(2);
|
|
231
|
+
}
|
|
232
|
+
const results = [];
|
|
233
|
+
let worstOk = true;
|
|
234
|
+
for (const [name, host, path] of targets) {
|
|
235
|
+
const layers = await probe(host, path, expect);
|
|
236
|
+
const v = verdict(layers);
|
|
237
|
+
if (v === 'fail' || (strict && v === 'warn'))
|
|
238
|
+
worstOk = false;
|
|
239
|
+
if (asJson)
|
|
240
|
+
results.push({ service: name, url: `https://${host}${path}`, verdict: v, layers });
|
|
241
|
+
else {
|
|
242
|
+
console.log(`\n${'='.repeat(60)}\n ${name} https://${host}${path}\n${'='.repeat(60)}`);
|
|
243
|
+
for (const l of layers)
|
|
244
|
+
console.log(` ${MARK[l.result] || '?'} ${l.layer.padEnd(11)} ${l.detail}`);
|
|
245
|
+
console.log(` ${'—'.repeat(56)}\n ${'结论'.padEnd(10)} ${VTXT[v]}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (asJson)
|
|
249
|
+
console.log(JSON.stringify(results, null, 2));
|
|
250
|
+
// 用 exitCode 而非 process.exit():后者会在管道(--json | jq)未 flush 完就退出而截断输出
|
|
251
|
+
process.exitCode = worstOk ? 0 : 1;
|
|
252
|
+
}
|
|
253
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optima-chat/dev-skills",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.38",
|
|
4
4
|
"description": "Claude Code Skills for Optima development team - cross-environment collaboration tools",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"optima-discount": "dist/bin/helpers/discount.js",
|
|
14
14
|
"optima-product": "dist/bin/helpers/product.js",
|
|
15
15
|
"optima-query-db": "dist/bin/helpers/query-db.js",
|
|
16
|
-
"optima-show-env": "dist/bin/helpers/show-env.js"
|
|
16
|
+
"optima-show-env": "dist/bin/helpers/show-env.js",
|
|
17
|
+
"optima-verify-health": "dist/bin/helpers/verify-health.js"
|
|
17
18
|
},
|
|
18
19
|
"scripts": {
|
|
19
20
|
"postinstall": "node scripts/install.js",
|