@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.
- package/.claude/commands/logs.md +55 -0
- package/.claude/skills/grant-balance/SKILL.md +10 -8
- package/.claude/skills/grant-subscription/SKILL.md +12 -6
- package/.claude/skills/logs/SKILL.md +14 -1
- package/.codex/skills/grant-balance/SKILL.md +10 -8
- package/.codex/skills/grant-subscription/SKILL.md +8 -1
- package/bin/cli.js +1 -0
- package/bin/helpers/billing-http.ts +83 -3
- package/bin/helpers/db-utils.ts +333 -23
- package/bin/helpers/grant-balance.ts +39 -46
- package/bin/helpers/grant-subscription.ts +47 -89
- package/bin/helpers/query-db.ts +25 -88
- package/bin/helpers/show-env.ts +3 -28
- package/bin/helpers/verify-health.ts +208 -0
- package/dist/bin/helpers/billing-http.js +82 -3
- package/dist/bin/helpers/db-utils.js +356 -27
- package/dist/bin/helpers/discount.js +0 -0
- package/dist/bin/helpers/entitlement.js +0 -0
- package/dist/bin/helpers/generate-test-token.js +0 -0
- package/dist/bin/helpers/grant-balance.js +35 -43
- package/dist/bin/helpers/grant-subscription.js +36 -86
- package/dist/bin/helpers/plugin.js +0 -0
- package/dist/bin/helpers/product.js +0 -0
- package/dist/bin/helpers/query-db.js +34 -71
- package/dist/bin/helpers/show-env.js +5 -17
- package/dist/bin/helpers/verify-health.js +253 -0
- package/package.json +3 -2
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
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 =
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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 => {
|
package/bin/helpers/query-db.ts
CHANGED
|
@@ -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
|
-
//
|
|
100
|
-
|
|
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 =
|
|
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);
|
package/bin/helpers/show-env.ts
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
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();
|