@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,23 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
// P15 D8b(optima-billing docs/2026-06-11-p15-wallet-sunset-spec.md):
|
|
5
|
+
// USD 钱包已退役——原「SSH 直写 usd_wallets.granted_balance_micros」作废,
|
|
6
|
+
// 改调 billing 服务态端点(grantCredits → bonus 积分)。
|
|
7
|
+
// ⚠️ 语义变化:旧 wallet granted 无期限;积分 bonus 桶标准 30 天有效期。
|
|
8
|
+
const crypto_1 = require("crypto");
|
|
4
9
|
const db_utils_1 = require("./db-utils");
|
|
10
|
+
const billing_http_1 = require("./billing-http");
|
|
5
11
|
function parseArgs(args) {
|
|
6
12
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
7
13
|
console.log(`Usage: optima-grant-balance <email> --amount <usd> [options]
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
Grant credits to a user (bonus bucket, expires in 30 days).
|
|
10
16
|
Used for promotional grants, compensation, referral rewards, etc.
|
|
17
|
+
$1 = 700 credits (P15 unified ledger; the USD wallet is retired).
|
|
11
18
|
|
|
12
19
|
Options:
|
|
13
|
-
--amount <usd> USD amount to grant (required, e.g. 5 for $5.00)
|
|
20
|
+
--amount <usd> USD amount to grant (required, e.g. 5 for $5.00 = 3500 credits)
|
|
14
21
|
--description <text> Description for audit trail (optional)
|
|
15
|
-
--env <env> Environment: stage, prod (default: stage)
|
|
22
|
+
--env <env> Environment: stage, prod, cn-prod (default: stage)
|
|
16
23
|
-h, --help Show this help
|
|
17
24
|
|
|
18
25
|
Examples:
|
|
19
26
|
optima-grant-balance user@example.com --amount 5 --env prod
|
|
20
|
-
optima-grant-balance user@example.com --amount 10 --description "Service outage compensation"
|
|
27
|
+
optima-grant-balance user@example.com --amount 10 --description "Service outage compensation"
|
|
28
|
+
optima-grant-balance user@example.com --amount 1 --env cn-prod # ¥-priced env, still USD input ($1 = 700 credits)`);
|
|
21
29
|
process.exit(0);
|
|
22
30
|
}
|
|
23
31
|
const email = args[0];
|
|
@@ -39,51 +47,35 @@ Examples:
|
|
|
39
47
|
console.error('--amount is required and must be > 0 (USD)');
|
|
40
48
|
process.exit(1);
|
|
41
49
|
}
|
|
42
|
-
|
|
43
|
-
console.error('Env must be stage or prod (billing DB not available in CI)');
|
|
44
|
-
process.exit(1);
|
|
45
|
-
}
|
|
50
|
+
(0, billing_http_1.validateEnvCnProd)(env);
|
|
46
51
|
return { email, amountUsd, description, env };
|
|
47
52
|
}
|
|
48
53
|
async function main() {
|
|
49
54
|
const { email, amountUsd, description, env } = parseArgs(process.argv.slice(2));
|
|
50
|
-
|
|
51
|
-
const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
|
|
52
|
-
// 1 USD = 1,000,000 micros. Round to integer micros.
|
|
53
|
-
const amountMicros = Math.round(amountUsd * 1000000);
|
|
54
|
-
console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} to ${email} [${env.toUpperCase()}]\n`);
|
|
55
|
+
console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${email} [${env.toUpperCase()}]\n`);
|
|
55
56
|
if (description)
|
|
56
57
|
console.log(` Reason: ${description}`);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
FROM usd_wallets w WHERE w.user_id = '${safeUserId}';
|
|
79
|
-
|
|
80
|
-
COMMIT;
|
|
81
|
-
`.trim();
|
|
82
|
-
bq(txSQL);
|
|
83
|
-
console.log(`✓ Granted $${amountUsd.toFixed(2)} to wallet`);
|
|
84
|
-
const balanceMicros = bq(`SELECT granted_balance_micros FROM usd_wallets WHERE user_id='${safeUserId}'`);
|
|
85
|
-
const balanceUsd = (parseInt(balanceMicros, 10) / 1000000).toFixed(2);
|
|
86
|
-
console.log(`\n✅ Done! ${email} now has $${balanceUsd} granted balance\n`);
|
|
58
|
+
// cn-prod has no SSH tunnel into the Aliyun RDS — resolve via user-auth's
|
|
59
|
+
// internal lookup API instead of the direct SQL path.
|
|
60
|
+
let userId;
|
|
61
|
+
if (env === 'cn-prod') {
|
|
62
|
+
userId = await (0, billing_http_1.resolveUserIdByEmail)(env, email);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
|
|
66
|
+
const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
|
|
67
|
+
userId = await (0, db_utils_1.resolveUserId)(email, env, infisicalConfig, token);
|
|
68
|
+
}
|
|
69
|
+
// 幂等键 per-invocation 生成、callBilling 的 5xx retry 复用同 body —— 「已
|
|
70
|
+
// commit 但响应 5xx」场景重试不双发(billing spec R2-M3)。
|
|
71
|
+
const { body } = await (0, billing_http_1.callBilling)(env, 'POST', '/api/billing/admin/grant-credits', {
|
|
72
|
+
userId,
|
|
73
|
+
amountUsd,
|
|
74
|
+
description: description ?? undefined,
|
|
75
|
+
idempotencyKey: `dev-skills-grant:${(0, crypto_1.randomUUID)()}`,
|
|
76
|
+
});
|
|
77
|
+
console.log(`✓ Granted ${body.credits} credits (lot ${body.lotId})`);
|
|
78
|
+
console.log(`\n✅ Done! ${email} received ${body.credits} bonus credits (expires in 30 days)\n`);
|
|
87
79
|
}
|
|
88
80
|
main().catch(error => {
|
|
89
81
|
console.error('\n❌ Error:', error.message);
|
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
// P15 D8b:USD 钱包退役——原「SSH 直写 subscriptions/usd_wallets/token_quotas」
|
|
5
|
+
// 作废,改调 billing 服务态端点(与用户态 /api/admin/grant-subscription 同
|
|
6
|
+
// 业务体:supersede 旧授予 + 实得 credits + token quota,重试双发不双倍)。
|
|
4
7
|
const db_utils_1 = require("./db-utils");
|
|
8
|
+
const billing_http_1 = require("./billing-http");
|
|
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 = {
|
|
13
|
+
stage: ['trial', 'starter', 'pro', 'enterprise'],
|
|
14
|
+
prod: ['trial', 'starter', 'pro', 'enterprise'],
|
|
15
|
+
'cn-prod': ['trial', 'starter-cn', 'pro-cn', 'enterprise-cn'],
|
|
16
|
+
};
|
|
5
17
|
function parseArgs(args) {
|
|
6
18
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
7
19
|
console.log(`Usage: optima-grant-subscription <email> [options]
|
|
8
20
|
|
|
9
21
|
Options:
|
|
10
22
|
--plan <id> Plan: trial, starter, pro, enterprise (default: pro)
|
|
23
|
+
cn-prod plans: trial, starter-cn, pro-cn, enterprise-cn (default: pro-cn)
|
|
11
24
|
--months <n> Duration in months (default: 1)
|
|
12
|
-
--env <env> Environment: stage, prod (default: stage)
|
|
25
|
+
--env <env> Environment: stage, prod, cn-prod (default: stage)
|
|
13
26
|
-h, --help Show this help`);
|
|
14
27
|
process.exit(0);
|
|
15
28
|
}
|
|
16
29
|
const email = args[0];
|
|
17
|
-
let plan =
|
|
30
|
+
let plan = null;
|
|
18
31
|
let months = 1;
|
|
19
32
|
let env = 'stage';
|
|
20
33
|
for (let i = 1; i < args.length; i++) {
|
|
@@ -28,103 +41,40 @@ Options:
|
|
|
28
41
|
env = args[++i];
|
|
29
42
|
}
|
|
30
43
|
}
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
(0, billing_http_1.validateEnvCnProd)(env);
|
|
45
|
+
plan = plan ?? (env === 'cn-prod' ? 'pro-cn' : 'pro');
|
|
46
|
+
const allowed = PLANS_BY_ENV[env];
|
|
47
|
+
if (!allowed.includes(plan)) {
|
|
48
|
+
console.error(`Unknown plan for ${env}: ${plan}. Available: ${allowed.join(', ')}`);
|
|
33
49
|
process.exit(1);
|
|
34
50
|
}
|
|
35
51
|
if (months < 1) {
|
|
36
52
|
console.error('Months must be >= 1');
|
|
37
53
|
process.exit(1);
|
|
38
54
|
}
|
|
39
|
-
if (!['stage', 'prod'].includes(env)) {
|
|
40
|
-
console.error('Env must be stage or prod (billing DB not available in CI)');
|
|
41
|
-
process.exit(1);
|
|
42
|
-
}
|
|
43
55
|
return { email, plan, months, env };
|
|
44
56
|
}
|
|
45
57
|
async function main() {
|
|
46
58
|
const { email, plan, months, env } = parseArgs(process.argv.slice(2));
|
|
47
|
-
const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
|
|
48
|
-
const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
|
|
49
59
|
console.log(`\n🎁 Granting ${plan} subscription to ${email} for ${months} month(s) [${env.toUpperCase()}]\n`);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const planRow = bq(`SELECT name, monthly_credits, session_token_limit, weekly_token_limit FROM plans WHERE id='${(0, db_utils_1.escapeSQL)(plan)}'`);
|
|
56
|
-
if (!planRow) {
|
|
57
|
-
console.error(`❌ Plan not found in DB: ${plan}`);
|
|
58
|
-
process.exit(1);
|
|
60
|
+
// cn-prod has no SSH tunnel into the Aliyun RDS — resolve via user-auth's
|
|
61
|
+
// internal lookup API instead of the direct SQL path.
|
|
62
|
+
let userId;
|
|
63
|
+
if (env === 'cn-prod') {
|
|
64
|
+
userId = await (0, billing_http_1.resolveUserIdByEmail)(env, email);
|
|
59
65
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const weeklyTokenLimit = parseInt(weeklyTokenLimitStr, 10);
|
|
65
|
-
console.log(`✓ Plan: ${planName} (grant: $${(grantMicros / 1000000).toFixed(2)}, session: ${sessionTokenLimit.toLocaleString()}, weekly: ${weeklyTokenLimit.toLocaleString()})`);
|
|
66
|
-
// Execute all mutations in a single transaction
|
|
67
|
-
const now = new Date().toISOString();
|
|
68
|
-
const periodEnd = new Date();
|
|
69
|
-
periodEnd.setMonth(periodEnd.getMonth() + months);
|
|
70
|
-
const periodEndISO = periodEnd.toISOString();
|
|
71
|
-
const sessionEnd = new Date(new Date().getTime() + 5 * 60 * 60 * 1000).toISOString();
|
|
72
|
-
const weekEnd = new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
73
|
-
const safeUserId = (0, db_utils_1.escapeSQL)(userId);
|
|
74
|
-
const safePlan = (0, db_utils_1.escapeSQL)(plan);
|
|
75
|
-
const safePlanName = (0, db_utils_1.escapeSQL)(planName);
|
|
76
|
-
console.log('Executing transaction...');
|
|
77
|
-
const txSQL = `
|
|
78
|
-
BEGIN;
|
|
79
|
-
|
|
80
|
-
-- Cancel active subscriptions
|
|
81
|
-
UPDATE subscriptions SET status='canceled', canceled_at='${now}'
|
|
82
|
-
WHERE user_id='${safeUserId}' AND status IN ('active','trialing');
|
|
83
|
-
|
|
84
|
-
-- Create new subscription
|
|
85
|
-
INSERT INTO subscriptions (id, user_id, plan_id, status, billing_interval, current_period_start, current_period_end, created_at, updated_at)
|
|
86
|
-
VALUES (concat('sub_gift_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'active', 'monthly', '${now}', '${periodEndISO}', '${now}', '${now}');
|
|
87
|
-
|
|
88
|
-
-- Ensure wallet exists (upsert)
|
|
89
|
-
INSERT INTO usd_wallets (id, user_id, balance_micros, reserved_micros, granted_balance_micros, created_at, updated_at)
|
|
90
|
-
VALUES (gen_random_uuid(), '${safeUserId}', 0, 0, 0, '${now}', '${now}')
|
|
91
|
-
ON CONFLICT (user_id) DO NOTHING;
|
|
92
|
-
|
|
93
|
-
-- Reset granted balance and set new grant
|
|
94
|
-
UPDATE usd_wallets SET granted_balance_micros = ${grantMicros}, updated_at = '${now}'
|
|
95
|
-
WHERE user_id = '${safeUserId}';
|
|
96
|
-
|
|
97
|
-
-- Record topup for audit trail
|
|
98
|
-
INSERT INTO usd_wallet_topups (id, wallet_id, amount_micros, service_fee_micros, net_credit_micros, status, source, service_namespace, created_at, completed_at)
|
|
99
|
-
SELECT gen_random_uuid(), w.id, ${grantMicros}, 0, ${grantMicros}, 'completed', 'subscription_grant', 'platform', '${now}', '${now}'
|
|
100
|
-
FROM usd_wallets w WHERE w.user_id = '${safeUserId}';
|
|
101
|
-
|
|
102
|
-
-- Update existing active session quota, or insert new one if none exists
|
|
103
|
-
UPDATE token_quotas SET plan_id='${safePlan}', monthly_limit=${sessionTokenLimit}, updated_at='${now}'
|
|
104
|
-
WHERE user_id='${safeUserId}' AND period_type='session' AND period_end > '${now}';
|
|
105
|
-
|
|
106
|
-
INSERT INTO token_quotas (id, user_id, plan_id, period_type, monthly_limit, monthly_used, period_start, period_end, created_at, updated_at)
|
|
107
|
-
SELECT concat('tq_sess_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'session', ${sessionTokenLimit}, 0, '${now}', '${sessionEnd}', '${now}', '${now}'
|
|
108
|
-
WHERE NOT EXISTS (SELECT 1 FROM token_quotas WHERE user_id='${safeUserId}' AND period_type='session' AND period_end > '${now}');
|
|
109
|
-
|
|
110
|
-
-- Update existing active weekly quota, or insert new one if none exists
|
|
111
|
-
UPDATE token_quotas SET plan_id='${safePlan}', monthly_limit=${weeklyTokenLimit}, updated_at='${now}'
|
|
112
|
-
WHERE user_id='${safeUserId}' AND period_type='weekly' AND period_end > '${now}';
|
|
113
|
-
|
|
114
|
-
INSERT INTO token_quotas (id, user_id, plan_id, period_type, monthly_limit, monthly_used, period_start, period_end, created_at, updated_at)
|
|
115
|
-
SELECT concat('tq_week_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'weekly', ${weeklyTokenLimit}, 0, '${now}', '${weekEnd}', '${now}', '${now}'
|
|
116
|
-
WHERE NOT EXISTS (SELECT 1 FROM token_quotas WHERE user_id='${safeUserId}' AND period_type='weekly' AND period_end > '${now}');
|
|
117
|
-
|
|
118
|
-
COMMIT;
|
|
119
|
-
`.trim();
|
|
120
|
-
bq(txSQL);
|
|
121
|
-
console.log('✓ Old subscriptions canceled');
|
|
122
|
-
console.log(`✓ ${planName} subscription created (expires: ${periodEnd.toLocaleDateString()})`);
|
|
123
|
-
if (grantMicros > 0) {
|
|
124
|
-
console.log(`✓ Wallet granted $${(grantMicros / 1000000).toFixed(2)} (${monthlyCredits} credits equivalent)`);
|
|
66
|
+
else {
|
|
67
|
+
const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
|
|
68
|
+
const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
|
|
69
|
+
userId = await (0, db_utils_1.resolveUserId)(email, env, infisicalConfig, token);
|
|
125
70
|
}
|
|
126
|
-
|
|
127
|
-
console.log(
|
|
71
|
+
const { body } = await (0, billing_http_1.callBilling)(env, 'POST', '/api/billing/admin/grant-subscription', { userId, planId: plan, months });
|
|
72
|
+
console.log(`✓ Subscription ${body.subscriptionId} (${body.planId})`);
|
|
73
|
+
console.log(`✓ Credits: ${body.credits.toLocaleString()} (expires ${body.expiresAt})`);
|
|
74
|
+
console.log(`✓ Token limits: session ${body.sessionTokenLimit.toLocaleString()} / weekly ${body.weeklyTokenLimit.toLocaleString()}`);
|
|
75
|
+
if (body.warning)
|
|
76
|
+
console.log(`⚠️ ${body.warning}`);
|
|
77
|
+
console.log(`\n✅ Done! ${email} now has ${body.planId} until ${body.expiresAt}\n`);
|
|
128
78
|
}
|
|
129
79
|
main().catch(error => {
|
|
130
80
|
console.error('\n❌ Error:', error.message);
|
|
File without changes
|
|
File without changes
|
|
@@ -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,64 +114,8 @@ 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
|
-
//
|
|
117
|
-
|
|
118
|
-
function parseDatabaseUrl(url) {
|
|
119
|
-
// postgresql://user:password@host:port/database?params
|
|
120
|
-
const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
|
|
121
|
-
if (!match) {
|
|
122
|
-
throw new Error(`Failed to parse DATABASE_URL: ${url}`);
|
|
123
|
-
}
|
|
124
|
-
return {
|
|
125
|
-
user: decodeURIComponent(match[1]),
|
|
126
|
-
password: decodeURIComponent(match[2]),
|
|
127
|
-
host: match[3],
|
|
128
|
-
port: parseInt(match[4]),
|
|
129
|
-
database: match[5]
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
function getGitHubVariable(name) {
|
|
133
|
-
return (0, child_process_1.execSync)(`gh variable get ${name} -R Optima-Chat/optima-dev-skills`, { encoding: 'utf-8' }).trim();
|
|
134
|
-
}
|
|
135
|
-
function getInfisicalConfig() {
|
|
136
|
-
return {
|
|
137
|
-
url: getGitHubVariable('INFISICAL_URL'),
|
|
138
|
-
clientId: getGitHubVariable('INFISICAL_CLIENT_ID'),
|
|
139
|
-
clientSecret: getGitHubVariable('INFISICAL_CLIENT_SECRET'),
|
|
140
|
-
projectId: getGitHubVariable('INFISICAL_PROJECT_ID')
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
function getInfisicalToken(config) {
|
|
144
|
-
const response = (0, child_process_1.execSync)(`curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`, { encoding: 'utf-8' });
|
|
145
|
-
return JSON.parse(response).accessToken;
|
|
146
|
-
}
|
|
147
|
-
function getInfisicalSecrets(config, token, environment, secretPath) {
|
|
148
|
-
const response = (0, child_process_1.execSync)(`curl -s "${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${secretPath}" -H "Authorization: Bearer ${token}"`, { encoding: 'utf-8' });
|
|
149
|
-
const data = JSON.parse(response);
|
|
150
|
-
const secrets = {};
|
|
151
|
-
for (const secret of data.secrets || []) {
|
|
152
|
-
secrets[secret.secretKey] = secret.secretValue;
|
|
153
|
-
}
|
|
154
|
-
return secrets;
|
|
155
|
-
}
|
|
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
|
-
}
|
|
117
|
+
// parseDatabaseUrl 统一用 db-utils 的实现:右切 userinfo 容忍密码特殊字符,
|
|
118
|
+
// 且报错不回显 URL(这里曾把含密码的完整 URL 打进错误信息)。
|
|
174
119
|
function findPsqlPath() {
|
|
175
120
|
// 1. 优先从 PATH 中查找
|
|
176
121
|
const whichCmd = process.platform === 'win32' ? 'where psql' : 'which psql';
|
|
@@ -225,7 +170,7 @@ async function main() {
|
|
|
225
170
|
console.error('Usage: query-db.ts <service> <sql> [environment]');
|
|
226
171
|
console.error('');
|
|
227
172
|
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');
|
|
228
|
-
console.error('Environments: ci (default), stage, prod');
|
|
173
|
+
console.error('Environments: ci (default), stage, prod, cn (阿里云 cn-prod)');
|
|
229
174
|
console.error('');
|
|
230
175
|
console.error('Example: query-db.ts user-auth "SELECT COUNT(*) FROM users" prod');
|
|
231
176
|
process.exit(1);
|
|
@@ -236,6 +181,27 @@ async function main() {
|
|
|
236
181
|
console.error('Available services:', Object.keys(SERVICE_DB_MAP).join(', '));
|
|
237
182
|
process.exit(1);
|
|
238
183
|
}
|
|
184
|
+
// cn-prod(阿里云):独立 Infisical + 经 buildbox 跳板连内网 RDS(动态端口隧道)。
|
|
185
|
+
// 两类 cred:① shared-secrets/database-users(按 prefix)② 服务自己的 DATABASE_URL(展开引用)。
|
|
186
|
+
if ((0, db_utils_1.isCnEnv)(environment)) {
|
|
187
|
+
const prodCfg = SERVICE_DB_MAP[service].prod;
|
|
188
|
+
let db;
|
|
189
|
+
if (prodCfg?.userKey) {
|
|
190
|
+
const prefix = prodCfg.userKey.replace(/_DB_USER$/, '');
|
|
191
|
+
console.log(`\n🔍 Querying ${service} (CN-PROD, prefix ${prefix})...`);
|
|
192
|
+
db = (0, db_utils_1.connectCnDB)(prefix);
|
|
193
|
+
}
|
|
194
|
+
else if (prodCfg?.databaseUrlPath) {
|
|
195
|
+
console.log(`\n🔍 Querying ${service} (CN-PROD, DATABASE_URL @ ${prodCfg.databaseUrlPath})...`);
|
|
196
|
+
db = (0, db_utils_1.connectCnDBFromUrl)(prodCfg.databaseUrlPath);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
console.error(`cn-prod query 暂不支持 ${service}(既无 userKey 也无 databaseUrlPath)。见 optima-dev-skills#21。`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
console.log('\n' + db.query(sql));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
239
205
|
const serviceConfig = SERVICE_DB_MAP[service][environment];
|
|
240
206
|
if (!serviceConfig) {
|
|
241
207
|
console.error(`Service ${service} is not available in ${environment.toUpperCase()} environment.`);
|
|
@@ -247,18 +213,18 @@ async function main() {
|
|
|
247
213
|
console.log(`\n🔍 Querying ${service} (${environment.toUpperCase()})...`);
|
|
248
214
|
if (environment === 'ci') {
|
|
249
215
|
// CI 环境:通过 SSH + Docker Exec
|
|
250
|
-
const ciUser = getGitHubVariable('CI_SSH_USER');
|
|
251
|
-
const ciHost = getGitHubVariable('CI_SSH_HOST');
|
|
252
|
-
const ciPassword = getGitHubVariable('CI_SSH_PASSWORD');
|
|
216
|
+
const ciUser = (0, db_utils_1.getGitHubVariable)('CI_SSH_USER');
|
|
217
|
+
const ciHost = (0, db_utils_1.getGitHubVariable)('CI_SSH_HOST');
|
|
218
|
+
const ciPassword = (0, db_utils_1.getGitHubVariable)('CI_SSH_PASSWORD');
|
|
253
219
|
const { container, user, database } = serviceConfig;
|
|
254
220
|
const result = (0, child_process_1.execSync)(`sshpass -p "${ciPassword}" ssh -o StrictHostKeyChecking=no ${ciUser}@${ciHost} "docker exec ${container} psql -U ${user} -d ${database} -c \\"${sql}\\""`, { encoding: 'utf-8' });
|
|
255
221
|
console.log('\n' + result);
|
|
256
222
|
}
|
|
257
223
|
else {
|
|
258
224
|
// Stage/Prod 环境:通过 SSH 隧道访问 RDS
|
|
259
|
-
const infisicalConfig = getInfisicalConfig();
|
|
225
|
+
const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
|
|
260
226
|
console.log('✓ Loaded Infisical config from GitHub Variables');
|
|
261
|
-
const token = getInfisicalToken(infisicalConfig);
|
|
227
|
+
const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
|
|
262
228
|
console.log('✓ Obtained Infisical access token');
|
|
263
229
|
const infisicalEnv = environment === 'stage' ? 'staging' : 'prod';
|
|
264
230
|
let dbUser;
|
|
@@ -268,13 +234,13 @@ async function main() {
|
|
|
268
234
|
if ('databaseUrlPath' in serviceConfig) {
|
|
269
235
|
// 从服务路径获取 DATABASE_URL 并解析
|
|
270
236
|
const { databaseUrlPath, databaseUrlKey } = serviceConfig;
|
|
271
|
-
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, databaseUrlPath);
|
|
237
|
+
const secrets = (0, db_utils_1.getInfisicalSecrets)(infisicalConfig, token, infisicalEnv, databaseUrlPath);
|
|
272
238
|
console.log(`✓ Retrieved DATABASE_URL from Infisical (path: ${databaseUrlPath})`);
|
|
273
239
|
const databaseUrl = secrets[databaseUrlKey];
|
|
274
240
|
if (!databaseUrl) {
|
|
275
241
|
throw new Error(`DATABASE_URL not found in Infisical at ${databaseUrlPath}`);
|
|
276
242
|
}
|
|
277
|
-
const parsed = parseDatabaseUrl(databaseUrl);
|
|
243
|
+
const parsed = (0, db_utils_1.parseDatabaseUrl)(databaseUrl);
|
|
278
244
|
dbUser = parsed.user;
|
|
279
245
|
dbPassword = parsed.password;
|
|
280
246
|
dbHost = parsed.host;
|
|
@@ -282,7 +248,7 @@ async function main() {
|
|
|
282
248
|
}
|
|
283
249
|
else {
|
|
284
250
|
// 从 shared-secrets/database-users 获取凭证
|
|
285
|
-
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/shared-secrets/database-users');
|
|
251
|
+
const secrets = (0, db_utils_1.getInfisicalSecrets)(infisicalConfig, token, infisicalEnv, '/shared-secrets/database-users');
|
|
286
252
|
console.log('✓ Retrieved database credentials from Infisical');
|
|
287
253
|
const { userKey, passwordKey } = serviceConfig;
|
|
288
254
|
database = serviceConfig.database;
|
|
@@ -293,10 +259,7 @@ async function main() {
|
|
|
293
259
|
throw new Error(`Database credentials not found in Infisical for ${service}. Keys: ${userKey}, ${passwordKey}`);
|
|
294
260
|
}
|
|
295
261
|
}
|
|
296
|
-
const localPort =
|
|
297
|
-
setupSSHTunnel(EC2_HOST, dbHost, localPort);
|
|
298
|
-
// 等待隧道建立
|
|
299
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
262
|
+
const localPort = (0, db_utils_1.ensureTunnel)(dbHost);
|
|
300
263
|
const result = queryDatabase('localhost', localPort, dbUser, dbPassword, database, sql);
|
|
301
264
|
console.log('\n' + result);
|
|
302
265
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
|
+
const db_utils_1 = require("./db-utils");
|
|
5
6
|
// 支持的服务列表(Infisical 路径为 /services/<service-name>)
|
|
6
7
|
const SUPPORTED_SERVICES = [
|
|
7
8
|
'ads-backend',
|
|
@@ -35,21 +36,8 @@ const ENV_MAP = {
|
|
|
35
36
|
stage: 'staging',
|
|
36
37
|
prod: 'prod'
|
|
37
38
|
};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
function getInfisicalConfig() {
|
|
42
|
-
return {
|
|
43
|
-
url: getGitHubVariable('INFISICAL_URL'),
|
|
44
|
-
clientId: getGitHubVariable('INFISICAL_CLIENT_ID'),
|
|
45
|
-
clientSecret: getGitHubVariable('INFISICAL_CLIENT_SECRET'),
|
|
46
|
-
projectId: getGitHubVariable('INFISICAL_PROJECT_ID')
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
function getInfisicalToken(config) {
|
|
50
|
-
const response = (0, child_process_1.execSync)(`curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`, { encoding: 'utf-8' });
|
|
51
|
-
return JSON.parse(response).accessToken;
|
|
52
|
-
}
|
|
39
|
+
// NOTE: getInfisicalSecrets is kept local because it encodes secretPath (encodeURIComponent),
|
|
40
|
+
// unlike db-utils' raw-path variant; getGitHubVariable/getInfisicalConfig/getInfisicalToken are shared from db-utils.
|
|
53
41
|
function getInfisicalSecrets(config, token, environment, secretPath) {
|
|
54
42
|
const response = (0, child_process_1.execSync)(`curl -s "${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${encodeURIComponent(secretPath)}" -H "Authorization: Bearer ${token}"`, { encoding: 'utf-8' });
|
|
55
43
|
const data = JSON.parse(response);
|
|
@@ -122,9 +110,9 @@ async function main() {
|
|
|
122
110
|
const secretPath = `/services/${service}`;
|
|
123
111
|
console.log(`\n🔍 Fetching environment variables for ${service} (${environment.toUpperCase()})...\n`);
|
|
124
112
|
try {
|
|
125
|
-
const infisicalConfig = getInfisicalConfig();
|
|
113
|
+
const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
|
|
126
114
|
console.log('✓ Loaded Infisical config from GitHub Variables');
|
|
127
|
-
const token = getInfisicalToken(infisicalConfig);
|
|
115
|
+
const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
|
|
128
116
|
console.log('✓ Obtained Infisical access token');
|
|
129
117
|
const infisicalEnv = ENV_MAP[environment];
|
|
130
118
|
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, secretPath);
|