@optima-chat/dev-skills 0.7.20 → 0.7.22
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 +12 -0
- package/.claude/commands/query-db.md +30 -0
- package/.claude/commands/restart-ecs.md +12 -0
- package/.claude/settings.local.json +51 -0
- package/bin/cli.js +1 -0
- package/bin/helpers/db-utils.ts +143 -0
- package/bin/helpers/grant-credits.ts +65 -0
- package/bin/helpers/grant-subscription.ts +122 -0
- package/bin/helpers/query-db.ts +66 -13
- package/bin/helpers/show-env.ts +4 -1
- package/dist/bin/helpers/db-utils.js +166 -0
- package/dist/bin/helpers/generate-test-token.js +0 -0
- package/dist/bin/helpers/grant-credits.js +71 -0
- package/dist/bin/helpers/grant-subscription.js +121 -0
- package/dist/bin/helpers/query-db.js +61 -11
- package/dist/bin/helpers/show-env.js +4 -1
- package/package.json +4 -2
package/.claude/commands/logs.md
CHANGED
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
- `commerce-rq-worker` - RQ 后台任务
|
|
33
33
|
- `commerce-rq-scheduler` - RQ 定时调度
|
|
34
34
|
- `optima-logistics` - 物流服务(仅 Stage/Prod)
|
|
35
|
+
- `billing` - 计费服务(仅 Stage/Prod)
|
|
36
|
+
- `browser-backend` - 浏览器自动化服务(仅 Stage/Prod)
|
|
37
|
+
- `optima-generation` - 内容生成服务(仅 Stage/Prod)
|
|
38
|
+
- `optima-generation-worker` - 内容生成 Worker(仅 Stage/Prod)
|
|
35
39
|
- `lines` (可选): 显示行数,默认 50
|
|
36
40
|
- `environment` (可选): 环境,默认 ci
|
|
37
41
|
- `ci` - CI 持续集成环境(开发环境,默认)
|
|
@@ -128,6 +132,10 @@ aws logs get-log-events --log-group-name /ecs/commerce-backend-stage --log-strea
|
|
|
128
132
|
- `commerce-rq-worker` → `/ecs/commerce-rq-worker-stage`
|
|
129
133
|
- `commerce-rq-scheduler` → `/ecs/commerce-rq-scheduler-stage`
|
|
130
134
|
- `optima-logistics` → `/ecs/optima-logistics-stage`
|
|
135
|
+
- `billing` → `/ecs/billing-stage`
|
|
136
|
+
- `browser-backend` → `/ecs/browser-backend-stage`
|
|
137
|
+
- `optima-generation` → `/ecs/optima-generation-stage`
|
|
138
|
+
- `optima-generation-worker` → `/ecs/optima-generation-worker-stage`
|
|
131
139
|
|
|
132
140
|
### 2. Prod 环境(environment = "prod")
|
|
133
141
|
|
|
@@ -167,6 +175,10 @@ aws logs get-log-events --log-group-name /ecs/commerce-backend-prod --log-stream
|
|
|
167
175
|
- `commerce-rq-worker` → `/ecs/commerce-rq-worker-prod`
|
|
168
176
|
- `commerce-rq-scheduler` → `/ecs/commerce-rq-scheduler-prod`
|
|
169
177
|
- `optima-logistics` → `/ecs/optima-logistics-prod`
|
|
178
|
+
- `billing` → `/ecs/billing-prod`
|
|
179
|
+
- `browser-backend` → `/ecs/browser-backend-prod`
|
|
180
|
+
- `optima-generation` → `/ecs/optima-generation-prod`
|
|
181
|
+
- `optima-generation-worker` → `/ecs/optima-generation-worker-prod`
|
|
170
182
|
|
|
171
183
|
**注意**: `optima-store` 仅在 Stage 环境部署
|
|
172
184
|
|
|
@@ -47,6 +47,10 @@ optima-query-db commerce-backend "SELECT * FROM products LIMIT 5" prod
|
|
|
47
47
|
- `agentic-chat` - AI 聊天服务数据库
|
|
48
48
|
- `bi-backend` - BI 后端数据库
|
|
49
49
|
- `session-gateway` - AI Shell 网关数据库
|
|
50
|
+
- `optima-logistics` - 物流服务数据库
|
|
51
|
+
- `billing` - 计费服务数据库
|
|
52
|
+
- `browser-backend` - 浏览器自动化服务数据库
|
|
53
|
+
- `optima-generation` - 内容生成服务数据库
|
|
50
54
|
- `sql` (必需): SQL 查询语句(用引号包裹)
|
|
51
55
|
- `environment` (可选): 环境,默认 ci
|
|
52
56
|
- `ci` - CI 持续集成环境(开发环境,默认)
|
|
@@ -245,9 +249,22 @@ pkill -f "ssh.*15432:${DATABASE_HOST}:5432"
|
|
|
245
249
|
- 用户: Infisical `AI_SHELL_DB_USER`
|
|
246
250
|
- 密码: Infisical `AI_SHELL_DB_PASSWORD`
|
|
247
251
|
|
|
252
|
+
- `billing`:
|
|
253
|
+
- 数据库: `optima_billing_stage`
|
|
254
|
+
- 凭证: Infisical `/services/billing` → `DATABASE_URL`
|
|
255
|
+
|
|
256
|
+
- `browser-backend`:
|
|
257
|
+
- 数据库: `optima_stage_browser`
|
|
258
|
+
- 凭证: Infisical `/services/browser-backend` → `DATABASE_URL`
|
|
259
|
+
|
|
260
|
+
- `optima-generation`:
|
|
261
|
+
- 数据库: `optima_generation_stage`
|
|
262
|
+
- 凭证: Infisical `/services/optima-generation` → `DATABASE_URL`
|
|
263
|
+
|
|
248
264
|
**说明**:
|
|
249
265
|
- Infisical 配置从 GitHub Variables 获取
|
|
250
266
|
- 数据库密钥从 Infisical 动态获取(项目: optima-secrets-v2, 环境: staging, 路径: /shared-secrets/database-users)
|
|
267
|
+
- billing、browser-backend、optima-generation 的凭证存在各自服务路径的 DATABASE_URL 中
|
|
251
268
|
- Stage RDS: `optima-stage-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com`
|
|
252
269
|
- Shared EC2 IP: `3.0.210.113`
|
|
253
270
|
- SSH 隧道: 本地端口 `15432` → Shared EC2 → Stage RDS `5432`
|
|
@@ -343,9 +360,22 @@ pkill -f "ssh.*15433:${DATABASE_HOST}:5432"
|
|
|
343
360
|
- 用户: Infisical `AI_SHELL_DB_USER`
|
|
344
361
|
- 密码: Infisical `AI_SHELL_DB_PASSWORD`
|
|
345
362
|
|
|
363
|
+
- `billing`:
|
|
364
|
+
- 数据库: `optima_billing`
|
|
365
|
+
- 凭证: Infisical `/services/billing` → `DATABASE_URL`
|
|
366
|
+
|
|
367
|
+
- `browser-backend`:
|
|
368
|
+
- 数据库: `optima_browser`
|
|
369
|
+
- 凭证: Infisical `/services/browser-backend` → `DATABASE_URL`
|
|
370
|
+
|
|
371
|
+
- `optima-generation`:
|
|
372
|
+
- 数据库: `optima_generation`
|
|
373
|
+
- 凭证: Infisical `/services/optima-generation` → `DATABASE_URL`
|
|
374
|
+
|
|
346
375
|
**说明**:
|
|
347
376
|
- Infisical 配置从 GitHub Variables 获取
|
|
348
377
|
- 数据库密钥从 Infisical 动态获取(项目: optima-secrets-v2, 环境: prod, 路径: /shared-secrets/database-users)
|
|
378
|
+
- billing、browser-backend、optima-generation 的凭证存在各自服务路径的 DATABASE_URL 中
|
|
349
379
|
- Prod RDS: `optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com`
|
|
350
380
|
- Shared EC2 IP: `3.0.210.113`
|
|
351
381
|
- SSH 隧道: 本地端口 `15433` → Shared EC2 → Prod RDS `5432`
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
- `optima-scout` - 产品研究工具
|
|
32
32
|
- `ai-shell-web-ui` - Shell Web UI
|
|
33
33
|
- `optima-store` - 商城前端(仅 Stage)
|
|
34
|
+
- `billing` - 计费服务
|
|
35
|
+
- `browser-backend` - 浏览器自动化服务
|
|
36
|
+
- `optima-generation` - 内容生成服务
|
|
37
|
+
- `optima-generation-worker` - 内容生成 Worker
|
|
34
38
|
- `pgbouncer` - 连接池(仅 Stage)
|
|
35
39
|
- `environment` (可选): 环境,默认 stage
|
|
36
40
|
- `stage` - Stage 预发布环境(默认,更安全)
|
|
@@ -93,6 +97,10 @@ aws ecs update-service \
|
|
|
93
97
|
- `optima-scout-stage`
|
|
94
98
|
- `ai-shell-web-ui-stage`
|
|
95
99
|
- `optima-store-stage`
|
|
100
|
+
- `billing-stage`
|
|
101
|
+
- `browser-backend-stage`
|
|
102
|
+
- `optima-generation-stage`
|
|
103
|
+
- `optima-generation-worker-stage`
|
|
96
104
|
- `pgbouncer-stage`
|
|
97
105
|
|
|
98
106
|
### 2. Prod 环境重启(environment = "prod")
|
|
@@ -126,6 +134,10 @@ aws ecs update-service \
|
|
|
126
134
|
- `bi-dashboard-prod`
|
|
127
135
|
- `optima-scout-prod`
|
|
128
136
|
- `ai-shell-web-ui-prod`
|
|
137
|
+
- `billing-prod`
|
|
138
|
+
- `browser-backend-prod`
|
|
139
|
+
- `optima-generation-prod`
|
|
140
|
+
- `optima-generation-worker-prod`
|
|
129
141
|
|
|
130
142
|
**注意**: `optima-store` 和 `pgbouncer` 仅在 Stage 环境部署
|
|
131
143
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"WebSearch",
|
|
5
|
+
"WebFetch(domain:code.claude.com)",
|
|
6
|
+
"WebFetch(domain:platform.claude.com)",
|
|
7
|
+
"WebFetch(domain:github.com)",
|
|
8
|
+
"Bash(gh repo view:*)",
|
|
9
|
+
"Bash(gh repo clone:*)",
|
|
10
|
+
"Bash(gh repo list:*)",
|
|
11
|
+
"Read(//private/tmp/optima-docs/**)",
|
|
12
|
+
"Read(//tmp/optima-docs/**)",
|
|
13
|
+
"Bash(git init:*)",
|
|
14
|
+
"Bash(gh repo create:*)",
|
|
15
|
+
"Read(//private/tmp/optima-workspace/**)",
|
|
16
|
+
"Read(//tmp/optima-workspace/**)",
|
|
17
|
+
"Read(//tmp/optima-workspace/.claude/commands/**)",
|
|
18
|
+
"Bash(git add:*)",
|
|
19
|
+
"Bash(git push:*)",
|
|
20
|
+
"Bash(find:*)",
|
|
21
|
+
"Bash(git commit:*)",
|
|
22
|
+
"Bash(aws logs get-log-events:*)",
|
|
23
|
+
"Bash(npm install:*)",
|
|
24
|
+
"Bash(optima-dev-skills:*)",
|
|
25
|
+
"Bash(optima-generate-test-token:*)",
|
|
26
|
+
"Bash(optima-query-db:*)",
|
|
27
|
+
"Bash(gh variable set:*)",
|
|
28
|
+
"Bash(npm publish:*)",
|
|
29
|
+
"Bash(python3:*)",
|
|
30
|
+
"Bash(gh api:*)",
|
|
31
|
+
"Bash(curl -s http://auth.optima.chat/openapi.json)",
|
|
32
|
+
"Bash(curl -s https://auth.optima.chat/openapi.json)",
|
|
33
|
+
"Bash(cat:*)",
|
|
34
|
+
"Bash(node /Users/verypro/optima-dev-skills/scripts/install.js:*)",
|
|
35
|
+
"Bash(aws logs tail:*)",
|
|
36
|
+
"Bash(grep:*)",
|
|
37
|
+
"Bash(npm view:*)",
|
|
38
|
+
"Bash(npm version:*)",
|
|
39
|
+
"Bash(git checkout:*)",
|
|
40
|
+
"Bash(git pull:*)",
|
|
41
|
+
"Bash(node scripts/install.js:*)",
|
|
42
|
+
"Bash(gh issue:*)",
|
|
43
|
+
"Bash(npm run:*)",
|
|
44
|
+
"Bash(gh pr:*)",
|
|
45
|
+
"Bash(node:*)",
|
|
46
|
+
"Bash(echo \"exit: $?\")"
|
|
47
|
+
],
|
|
48
|
+
"deny": [],
|
|
49
|
+
"ask": []
|
|
50
|
+
}
|
|
51
|
+
}
|
package/bin/cli.js
CHANGED
|
@@ -38,6 +38,7 @@ switch (command) {
|
|
|
38
38
|
|
|
39
39
|
log('\nSupported Services:', 'yellow');
|
|
40
40
|
log(' commerce-backend user-auth mcp-host agentic-chat optima-logistics', 'cyan');
|
|
41
|
+
log(' optima-scout billing browser-backend optima-generation', 'cyan');
|
|
41
42
|
|
|
42
43
|
log('\nEnvironments:', 'yellow');
|
|
43
44
|
log(' stage (default) prod', 'cyan');
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
|
|
4
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
5
|
+
export interface InfisicalConfig { url: string; clientId: string; clientSecret: string; projectId: string }
|
|
6
|
+
|
|
7
|
+
export interface DBConnection {
|
|
8
|
+
host: string;
|
|
9
|
+
port: number;
|
|
10
|
+
user: string;
|
|
11
|
+
password: string;
|
|
12
|
+
database: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
16
|
+
export const RDS_HOSTS: Record<string, string> = {
|
|
17
|
+
stage: 'optima-stage-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
|
|
18
|
+
prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const EC2_HOST = '3.0.210.113';
|
|
22
|
+
|
|
23
|
+
// ─── SQL escaping ───────────────────────────────────────────────────────────
|
|
24
|
+
/** Escape a string value for safe inclusion in SQL single-quoted literals. */
|
|
25
|
+
export function escapeSQL(value: string): string {
|
|
26
|
+
return value.replace(/'/g, "''").replace(/\\/g, '\\\\');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── GitHub Variables ───────────────────────────────────────────────────────
|
|
30
|
+
export function getGitHubVariable(name: string): string {
|
|
31
|
+
return execSync(`gh variable get ${name} -R Optima-Chat/optima-dev-skills`, { encoding: 'utf-8' }).trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Infisical ──────────────────────────────────────────────────────────────
|
|
35
|
+
export function getInfisicalConfig(): InfisicalConfig {
|
|
36
|
+
return {
|
|
37
|
+
url: getGitHubVariable('INFISICAL_URL'),
|
|
38
|
+
clientId: getGitHubVariable('INFISICAL_CLIENT_ID'),
|
|
39
|
+
clientSecret: getGitHubVariable('INFISICAL_CLIENT_SECRET'),
|
|
40
|
+
projectId: getGitHubVariable('INFISICAL_PROJECT_ID'),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getInfisicalToken(config: InfisicalConfig): string {
|
|
45
|
+
const response = execSync(
|
|
46
|
+
`curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`,
|
|
47
|
+
{ encoding: 'utf-8' }
|
|
48
|
+
);
|
|
49
|
+
return JSON.parse(response).accessToken;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record<string, string> {
|
|
53
|
+
const response = execSync(
|
|
54
|
+
`curl -s "${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${secretPath}" -H "Authorization: Bearer ${token}"`,
|
|
55
|
+
{ encoding: 'utf-8' }
|
|
56
|
+
);
|
|
57
|
+
const data = JSON.parse(response);
|
|
58
|
+
const secrets: Record<string, string> = {};
|
|
59
|
+
for (const secret of data.secrets || []) {
|
|
60
|
+
secrets[secret.secretKey] = secret.secretValue;
|
|
61
|
+
}
|
|
62
|
+
return secrets;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Database URL parsing ───────────────────────────────────────────────────
|
|
66
|
+
export function parseDatabaseUrl(url: string): { user: string; password: string; host: string; port: number; database: string } {
|
|
67
|
+
const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
|
|
68
|
+
if (!match) throw new Error('Failed to parse DATABASE_URL (format: postgresql://user:pass@host:port/db)');
|
|
69
|
+
return { user: decodeURIComponent(match[1]), password: decodeURIComponent(match[2]), host: match[3], port: parseInt(match[4], 10), database: match[5] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── SSH tunnel ─────────────────────────────────────────────────────────────
|
|
73
|
+
export function setupSSHTunnel(dbHost: string, localPort: number): void {
|
|
74
|
+
try { execSync(`lsof -ti:${localPort}`, { stdio: 'ignore' }); return; } catch { /* need tunnel */ }
|
|
75
|
+
const sshKeyPath = `${process.env.HOME}/.ssh/optima-ec2-key`;
|
|
76
|
+
if (!fs.existsSync(sshKeyPath)) throw new Error(`SSH key not found: ${sshKeyPath}. Please obtain optima-ec2-key from xbfool.`);
|
|
77
|
+
console.log(`Creating SSH tunnel: localhost:${localPort} -> ${EC2_HOST} -> ${dbHost}:5432`);
|
|
78
|
+
execSync(`ssh -i ${sshKeyPath} -f -N -o StrictHostKeyChecking=no -L ${localPort}:${dbHost}:5432 ec2-user@${EC2_HOST}`, { stdio: 'inherit' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── psql ───────────────────────────────────────────────────────────────────
|
|
82
|
+
function findPsqlPath(): string {
|
|
83
|
+
try {
|
|
84
|
+
const result = execSync('which psql', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
|
|
85
|
+
if (result.trim()) return result.trim();
|
|
86
|
+
} catch { /* fallback */ }
|
|
87
|
+
const paths = ['/usr/local/opt/postgresql@16/bin/psql', '/usr/local/opt/postgresql@15/bin/psql', '/opt/homebrew/bin/psql', '/usr/bin/psql', '/usr/local/bin/psql'];
|
|
88
|
+
for (const p of paths) { if (fs.existsSync(p)) return p; }
|
|
89
|
+
throw new Error('PostgreSQL client (psql) not found. Install with: brew install postgresql@16');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function queryDB(conn: DBConnection, sql: string): string {
|
|
93
|
+
const psql = findPsqlPath();
|
|
94
|
+
return execSync(`"${psql}" -h ${conn.host} -p ${conn.port} -U ${conn.user} -d ${conn.database} -t -A --quiet -c "${sql.replace(/"/g, '\\"')}"`, {
|
|
95
|
+
encoding: 'utf-8',
|
|
96
|
+
env: { ...process.env, PGPASSWORD: conn.password },
|
|
97
|
+
}).trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── High-level connection helpers ──────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/** Connect to user-auth DB and return a query function. */
|
|
103
|
+
export async function connectAuthDB(env: string, infisicalConfig: InfisicalConfig, token: string): Promise<{ query: (sql: string) => string }> {
|
|
104
|
+
const infisicalEnv = env === 'stage' ? 'staging' : 'prod';
|
|
105
|
+
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/shared-secrets/database-users');
|
|
106
|
+
const host = RDS_HOSTS[env as 'stage' | 'prod'];
|
|
107
|
+
const port = env === 'stage' ? 15432 : 15433;
|
|
108
|
+
|
|
109
|
+
setupSSHTunnel(host, port);
|
|
110
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
111
|
+
|
|
112
|
+
const conn: DBConnection = { host: 'localhost', port, user: secrets['AUTH_DB_USER'], password: secrets['AUTH_DB_PASSWORD'], database: 'optima_auth' };
|
|
113
|
+
return { query: (sql: string) => queryDB(conn, sql) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Connect to billing DB and return a query function. */
|
|
117
|
+
export async function connectBillingDB(env: string, infisicalConfig: InfisicalConfig, token: string): Promise<{ query: (sql: string) => string }> {
|
|
118
|
+
const infisicalEnv = env === 'stage' ? 'staging' : 'prod';
|
|
119
|
+
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/services/billing');
|
|
120
|
+
const dbUrl = secrets['DATABASE_URL'];
|
|
121
|
+
if (!dbUrl) throw new Error('DATABASE_URL not found for billing service');
|
|
122
|
+
|
|
123
|
+
const parsed = parseDatabaseUrl(dbUrl);
|
|
124
|
+
const port = env === 'stage' ? 15434 : 15435;
|
|
125
|
+
setupSSHTunnel(parsed.host, port);
|
|
126
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
127
|
+
|
|
128
|
+
const conn: DBConnection = { host: 'localhost', port, user: parsed.user, password: parsed.password, database: parsed.database };
|
|
129
|
+
return { query: (sql: string) => queryDB(conn, sql) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Look up user_id by email from user-auth DB. Throws if not found. */
|
|
133
|
+
export async function resolveUserId(email: string, env: string, infisicalConfig: InfisicalConfig, token: string): Promise<string> {
|
|
134
|
+
console.log(`Looking up user by email: ${email}`);
|
|
135
|
+
const auth = await connectAuthDB(env, infisicalConfig, token);
|
|
136
|
+
const userId = auth.query(`SELECT id FROM users WHERE email='${escapeSQL(email)}' LIMIT 1`);
|
|
137
|
+
if (!userId) {
|
|
138
|
+
console.error(`❌ User not found: ${email}`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
console.log(`✓ Found user: ${userId}`);
|
|
142
|
+
return userId;
|
|
143
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { getInfisicalConfig, getInfisicalToken, resolveUserId, connectBillingDB, escapeSQL } from './db-utils';
|
|
4
|
+
|
|
5
|
+
function parseArgs(args: string[]): { email: string; amount: number; type: string; description: string | null; env: string } {
|
|
6
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
7
|
+
console.log(`Usage: optima-grant-credits <email> --amount <n> [options]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--amount <n> Credits to grant (required)
|
|
11
|
+
--type <type> Credit type: bonus, referral (default: bonus)
|
|
12
|
+
--description <text> Description (optional)
|
|
13
|
+
--env <env> Environment: stage, prod (default: stage)
|
|
14
|
+
-h, --help Show this help`);
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const email = args[0];
|
|
19
|
+
let amount = 0;
|
|
20
|
+
let type = 'bonus';
|
|
21
|
+
let description: string | null = null;
|
|
22
|
+
let env = 'stage';
|
|
23
|
+
|
|
24
|
+
for (let i = 1; i < args.length; i++) {
|
|
25
|
+
if (args[i] === '--amount' && args[i + 1]) { amount = parseInt(args[++i], 10); }
|
|
26
|
+
else if (args[i] === '--type' && args[i + 1]) { type = args[++i]; }
|
|
27
|
+
else if (args[i] === '--description' && args[i + 1]) { description = args[++i]; }
|
|
28
|
+
else if (args[i] === '--env' && args[i + 1]) { env = args[++i]; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (amount < 1) { console.error('--amount is required and must be >= 1'); process.exit(1); }
|
|
32
|
+
if (!['bonus', 'referral'].includes(type)) { console.error(`Unknown type: ${type}. Available: bonus, referral`); 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
|
+
|
|
35
|
+
return { email, amount, type, description, env };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function main() {
|
|
39
|
+
const { email, amount, type, description, env } = parseArgs(process.argv.slice(2));
|
|
40
|
+
const infisicalConfig = getInfisicalConfig();
|
|
41
|
+
const token = getInfisicalToken(infisicalConfig);
|
|
42
|
+
|
|
43
|
+
console.log(`\n🎁 Granting ${amount} ${type} credits to ${email} [${env.toUpperCase()}]\n`);
|
|
44
|
+
|
|
45
|
+
const userId = await resolveUserId(email, env, infisicalConfig, token);
|
|
46
|
+
const billing = await connectBillingDB(env, infisicalConfig, token);
|
|
47
|
+
const bq = billing.query;
|
|
48
|
+
|
|
49
|
+
const now = new Date().toISOString();
|
|
50
|
+
const safeUserId = escapeSQL(userId);
|
|
51
|
+
const safeType = escapeSQL(type);
|
|
52
|
+
const safeDesc = escapeSQL(description || `Admin ${type} credit grant`);
|
|
53
|
+
|
|
54
|
+
console.log(`Inserting ${amount} ${type} credits...`);
|
|
55
|
+
const ledgerId = bq(`INSERT INTO credit_ledger (id, user_id, type, description, initial_amount, remaining, created_at) VALUES (concat('crd_${safeType}_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safeType}', '${safeDesc}', ${amount}, ${amount}, '${now}') RETURNING id`);
|
|
56
|
+
console.log(`✓ Credits granted (ledger ID: ${ledgerId})`);
|
|
57
|
+
|
|
58
|
+
const balance = bq(`SELECT COALESCE(SUM(remaining), 0) FROM credit_ledger WHERE user_id='${safeUserId}' AND remaining > 0 AND (expires_at IS NULL OR expires_at > NOW())`);
|
|
59
|
+
console.log(`\n✅ Done! ${email} now has ${balance} total credits\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
main().catch(error => {
|
|
63
|
+
console.error('\n❌ Error:', error.message);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { getInfisicalConfig, getInfisicalToken, resolveUserId, connectBillingDB, escapeSQL } from './db-utils';
|
|
4
|
+
|
|
5
|
+
function parseArgs(args: string[]): { email: string; plan: string; months: number; env: string } {
|
|
6
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
7
|
+
console.log(`Usage: optima-grant-subscription <email> [options]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--plan <id> Plan: free, pro, enterprise (default: enterprise)
|
|
11
|
+
--months <n> Duration in months (default: 1)
|
|
12
|
+
--env <env> Environment: stage, prod (default: stage)
|
|
13
|
+
-h, --help Show this help`);
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const email = args[0];
|
|
18
|
+
let plan = 'enterprise';
|
|
19
|
+
let months = 1;
|
|
20
|
+
let env = 'stage';
|
|
21
|
+
|
|
22
|
+
for (let i = 1; i < args.length; i++) {
|
|
23
|
+
if (args[i] === '--plan' && args[i + 1]) { plan = args[++i]; }
|
|
24
|
+
else if (args[i] === '--months' && args[i + 1]) { months = parseInt(args[++i], 10); }
|
|
25
|
+
else if (args[i] === '--env' && args[i + 1]) { env = args[++i]; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!['free', 'pro', 'enterprise'].includes(plan)) {
|
|
29
|
+
console.error(`Unknown plan: ${plan}. Available: free, pro, enterprise`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
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
|
+
|
|
35
|
+
return { email, plan, months, env };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function main() {
|
|
39
|
+
const { email, plan, months, env } = parseArgs(process.argv.slice(2));
|
|
40
|
+
const infisicalConfig = getInfisicalConfig();
|
|
41
|
+
const token = getInfisicalToken(infisicalConfig);
|
|
42
|
+
|
|
43
|
+
console.log(`\n🎁 Granting ${plan} subscription to ${email} for ${months} month(s) [${env.toUpperCase()}]\n`);
|
|
44
|
+
|
|
45
|
+
const userId = await resolveUserId(email, env, infisicalConfig, token);
|
|
46
|
+
const billing = await connectBillingDB(env, infisicalConfig, token);
|
|
47
|
+
const bq = billing.query;
|
|
48
|
+
|
|
49
|
+
// Read plan config from DB
|
|
50
|
+
console.log(`Loading plan config: ${plan}`);
|
|
51
|
+
const planRow = bq(`SELECT name, monthly_credits, session_token_limit, weekly_token_limit FROM plans WHERE id='${escapeSQL(plan)}'`);
|
|
52
|
+
if (!planRow) { console.error(`❌ Plan not found in DB: ${plan}`); process.exit(1); }
|
|
53
|
+
|
|
54
|
+
const [planName, monthlyCreditsStr, sessionTokenLimitStr, weeklyTokenLimitStr] = planRow.split('|');
|
|
55
|
+
const monthlyCredits = parseInt(monthlyCreditsStr, 10);
|
|
56
|
+
const sessionTokenLimit = parseInt(sessionTokenLimitStr, 10);
|
|
57
|
+
const weeklyTokenLimit = parseInt(weeklyTokenLimitStr, 10);
|
|
58
|
+
console.log(`✓ Plan: ${planName} (credits: ${monthlyCredits}, session: ${sessionTokenLimit.toLocaleString()}, weekly: ${weeklyTokenLimit.toLocaleString()})`);
|
|
59
|
+
|
|
60
|
+
// Execute all mutations in a single transaction
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
const periodEnd = new Date();
|
|
63
|
+
periodEnd.setMonth(periodEnd.getMonth() + months);
|
|
64
|
+
const periodEndISO = periodEnd.toISOString();
|
|
65
|
+
const sessionEnd = new Date(new Date().getTime() + 5 * 60 * 60 * 1000).toISOString();
|
|
66
|
+
const weekEnd = new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
67
|
+
|
|
68
|
+
const safeUserId = escapeSQL(userId);
|
|
69
|
+
const safePlan = escapeSQL(plan);
|
|
70
|
+
const safePlanName = escapeSQL(planName);
|
|
71
|
+
|
|
72
|
+
console.log('Executing transaction...');
|
|
73
|
+
const txSQL = `
|
|
74
|
+
BEGIN;
|
|
75
|
+
|
|
76
|
+
-- Cancel active subscriptions
|
|
77
|
+
UPDATE subscriptions SET status='canceled', canceled_at='${now}'
|
|
78
|
+
WHERE user_id='${safeUserId}' AND status IN ('active','trialing');
|
|
79
|
+
|
|
80
|
+
-- Zero out old subscription credits
|
|
81
|
+
UPDATE credit_ledger SET remaining=0
|
|
82
|
+
WHERE user_id='${safeUserId}' AND type IN ('monthly_grant','subscription') AND remaining > 0;
|
|
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
|
+
-- Grant monthly credits
|
|
89
|
+
INSERT INTO credit_ledger (id, user_id, type, description, initial_amount, remaining, expires_at, created_at)
|
|
90
|
+
SELECT concat('crd_gift_', substr(md5(random()::text), 1, 16)), '${safeUserId}', 'subscription', '${safePlanName} plan gift (${months} month)', ${monthlyCredits}, ${monthlyCredits}, '${periodEndISO}', '${now}'
|
|
91
|
+
WHERE ${monthlyCredits} > 0;
|
|
92
|
+
|
|
93
|
+
-- Upsert session token quota
|
|
94
|
+
INSERT INTO token_quotas (id, user_id, plan_id, period_type, monthly_limit, monthly_used, period_start, period_end, created_at, updated_at)
|
|
95
|
+
VALUES (concat('tq_sess_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'session', ${sessionTokenLimit}, 0, '${now}', '${sessionEnd}', '${now}', '${now}')
|
|
96
|
+
ON CONFLICT (user_id, period_type, period_start) DO UPDATE SET plan_id='${safePlan}', monthly_limit=${sessionTokenLimit}, updated_at='${now}';
|
|
97
|
+
|
|
98
|
+
-- Upsert weekly token quota
|
|
99
|
+
INSERT INTO token_quotas (id, user_id, plan_id, period_type, monthly_limit, monthly_used, period_start, period_end, created_at, updated_at)
|
|
100
|
+
VALUES (concat('tq_week_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'weekly', ${weeklyTokenLimit}, 0, '${now}', '${weekEnd}', '${now}', '${now}')
|
|
101
|
+
ON CONFLICT (user_id, period_type, period_start) DO UPDATE SET plan_id='${safePlan}', monthly_limit=${weeklyTokenLimit}, updated_at='${now}';
|
|
102
|
+
|
|
103
|
+
COMMIT;
|
|
104
|
+
`.trim();
|
|
105
|
+
|
|
106
|
+
bq(txSQL);
|
|
107
|
+
|
|
108
|
+
console.log('✓ Old subscriptions canceled');
|
|
109
|
+
console.log('✓ Old credits cleared');
|
|
110
|
+
console.log(`✓ ${planName} subscription created (expires: ${periodEnd.toLocaleDateString()})`);
|
|
111
|
+
if (monthlyCredits > 0) {
|
|
112
|
+
console.log(`✓ ${monthlyCredits} credits granted`);
|
|
113
|
+
}
|
|
114
|
+
console.log(`✓ Token quotas updated (session: ${sessionTokenLimit.toLocaleString()}, weekly: ${weeklyTokenLimit.toLocaleString()})`);
|
|
115
|
+
|
|
116
|
+
console.log(`\n✅ Done! ${email} now has ${planName} plan until ${periodEnd.toLocaleDateString()}\n`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main().catch(error => {
|
|
120
|
+
console.error('\n❌ Error:', error.message);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
package/bin/helpers/query-db.ts
CHANGED
|
@@ -47,6 +47,21 @@ const SERVICE_DB_MAP = {
|
|
|
47
47
|
ci: null,
|
|
48
48
|
stage: { userKey: 'LOGISTICS_DB_USER', passwordKey: 'LOGISTICS_DB_PASSWORD', database: 'optima_stage_logistics' },
|
|
49
49
|
prod: { userKey: 'LOGISTICS_DB_USER', passwordKey: 'LOGISTICS_DB_PASSWORD', database: 'optima_logistics' }
|
|
50
|
+
},
|
|
51
|
+
'billing': {
|
|
52
|
+
ci: null,
|
|
53
|
+
stage: { databaseUrlPath: '/services/billing', databaseUrlKey: 'DATABASE_URL' },
|
|
54
|
+
prod: { databaseUrlPath: '/services/billing', databaseUrlKey: 'DATABASE_URL' }
|
|
55
|
+
},
|
|
56
|
+
'browser-backend': {
|
|
57
|
+
ci: null,
|
|
58
|
+
stage: { databaseUrlPath: '/services/browser-backend', databaseUrlKey: 'DATABASE_URL' },
|
|
59
|
+
prod: { databaseUrlPath: '/services/browser-backend', databaseUrlKey: 'DATABASE_URL' }
|
|
60
|
+
},
|
|
61
|
+
'optima-generation': {
|
|
62
|
+
ci: null,
|
|
63
|
+
stage: { databaseUrlPath: '/services/optima-generation', databaseUrlKey: 'DATABASE_URL' },
|
|
64
|
+
prod: { databaseUrlPath: '/services/optima-generation', databaseUrlKey: 'DATABASE_URL' }
|
|
50
65
|
}
|
|
51
66
|
};
|
|
52
67
|
|
|
@@ -59,6 +74,21 @@ const RDS_HOSTS = {
|
|
|
59
74
|
// 统一使用 BI Data ARM Host 作为跳板机
|
|
60
75
|
const EC2_HOST = '3.0.210.113';
|
|
61
76
|
|
|
77
|
+
function parseDatabaseUrl(url: string): { user: string; password: string; host: string; port: number; database: string } {
|
|
78
|
+
// postgresql://user:password@host:port/database?params
|
|
79
|
+
const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
|
|
80
|
+
if (!match) {
|
|
81
|
+
throw new Error(`Failed to parse DATABASE_URL: ${url}`);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
user: decodeURIComponent(match[1]),
|
|
85
|
+
password: decodeURIComponent(match[2]),
|
|
86
|
+
host: match[3],
|
|
87
|
+
port: parseInt(match[4]),
|
|
88
|
+
database: match[5]
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
62
92
|
function getGitHubVariable(name: string): string {
|
|
63
93
|
return execSync(`gh variable get ${name} -R Optima-Chat/optima-dev-skills`, { encoding: 'utf-8' }).trim();
|
|
64
94
|
}
|
|
@@ -179,7 +209,7 @@ async function main() {
|
|
|
179
209
|
if (args.length < 2) {
|
|
180
210
|
console.error('Usage: query-db.ts <service> <sql> [environment]');
|
|
181
211
|
console.error('');
|
|
182
|
-
console.error('Services: commerce-backend, user-auth, agentic-chat, bi-backend, session-gateway');
|
|
212
|
+
console.error('Services: commerce-backend, user-auth, agentic-chat, bi-backend, session-gateway, optima-logistics, billing, browser-backend, optima-generation');
|
|
183
213
|
console.error('Environments: ci (default), stage, prod');
|
|
184
214
|
console.error('');
|
|
185
215
|
console.error('Example: query-db.ts user-auth "SELECT COUNT(*) FROM users" prod');
|
|
@@ -228,19 +258,42 @@ async function main() {
|
|
|
228
258
|
const token = getInfisicalToken(infisicalConfig);
|
|
229
259
|
console.log('✓ Obtained Infisical access token');
|
|
230
260
|
|
|
231
|
-
// 数据库凭证存储在 Infisical 的 /shared-secrets/database-users 路径
|
|
232
|
-
// Stage 从 staging 环境读取,Prod 从 prod 环境读取
|
|
233
261
|
const infisicalEnv = environment === 'stage' ? 'staging' : 'prod';
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
262
|
+
let dbUser: string;
|
|
263
|
+
let dbPassword: string;
|
|
264
|
+
let dbHost: string;
|
|
265
|
+
let database: string;
|
|
266
|
+
|
|
267
|
+
if ('databaseUrlPath' in (serviceConfig as any)) {
|
|
268
|
+
// 从服务路径获取 DATABASE_URL 并解析
|
|
269
|
+
const { databaseUrlPath, databaseUrlKey } = serviceConfig as any;
|
|
270
|
+
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, databaseUrlPath);
|
|
271
|
+
console.log(`✓ Retrieved DATABASE_URL from Infisical (path: ${databaseUrlPath})`);
|
|
272
|
+
|
|
273
|
+
const databaseUrl = secrets[databaseUrlKey];
|
|
274
|
+
if (!databaseUrl) {
|
|
275
|
+
throw new Error(`DATABASE_URL not found in Infisical at ${databaseUrlPath}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const parsed = parseDatabaseUrl(databaseUrl);
|
|
279
|
+
dbUser = parsed.user;
|
|
280
|
+
dbPassword = parsed.password;
|
|
281
|
+
dbHost = parsed.host;
|
|
282
|
+
database = parsed.database;
|
|
283
|
+
} else {
|
|
284
|
+
// 从 shared-secrets/database-users 获取凭证
|
|
285
|
+
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/shared-secrets/database-users');
|
|
286
|
+
console.log('✓ Retrieved database credentials from Infisical');
|
|
287
|
+
|
|
288
|
+
const { userKey, passwordKey } = serviceConfig as any;
|
|
289
|
+
database = (serviceConfig as any).database;
|
|
290
|
+
dbHost = RDS_HOSTS[environment as 'stage' | 'prod'];
|
|
291
|
+
dbUser = secrets[userKey];
|
|
292
|
+
dbPassword = secrets[passwordKey];
|
|
293
|
+
|
|
294
|
+
if (!dbUser || !dbPassword) {
|
|
295
|
+
throw new Error(`Database credentials not found in Infisical for ${service}. Keys: ${userKey}, ${passwordKey}`);
|
|
296
|
+
}
|
|
244
297
|
}
|
|
245
298
|
|
|
246
299
|
const localPort = environment === 'stage' ? 15432 : 15433;
|
package/bin/helpers/show-env.ts
CHANGED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.RDS_HOSTS = void 0;
|
|
37
|
+
exports.escapeSQL = escapeSQL;
|
|
38
|
+
exports.getGitHubVariable = getGitHubVariable;
|
|
39
|
+
exports.getInfisicalConfig = getInfisicalConfig;
|
|
40
|
+
exports.getInfisicalToken = getInfisicalToken;
|
|
41
|
+
exports.getInfisicalSecrets = getInfisicalSecrets;
|
|
42
|
+
exports.parseDatabaseUrl = parseDatabaseUrl;
|
|
43
|
+
exports.setupSSHTunnel = setupSSHTunnel;
|
|
44
|
+
exports.queryDB = queryDB;
|
|
45
|
+
exports.connectAuthDB = connectAuthDB;
|
|
46
|
+
exports.connectBillingDB = connectBillingDB;
|
|
47
|
+
exports.resolveUserId = resolveUserId;
|
|
48
|
+
const child_process_1 = require("child_process");
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
51
|
+
exports.RDS_HOSTS = {
|
|
52
|
+
stage: 'optima-stage-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
|
|
53
|
+
prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
|
|
54
|
+
};
|
|
55
|
+
const EC2_HOST = '3.0.210.113';
|
|
56
|
+
// ─── SQL escaping ───────────────────────────────────────────────────────────
|
|
57
|
+
/** Escape a string value for safe inclusion in SQL single-quoted literals. */
|
|
58
|
+
function escapeSQL(value) {
|
|
59
|
+
return value.replace(/'/g, "''").replace(/\\/g, '\\\\');
|
|
60
|
+
}
|
|
61
|
+
// ─── GitHub Variables ───────────────────────────────────────────────────────
|
|
62
|
+
function getGitHubVariable(name) {
|
|
63
|
+
return (0, child_process_1.execSync)(`gh variable get ${name} -R Optima-Chat/optima-dev-skills`, { encoding: 'utf-8' }).trim();
|
|
64
|
+
}
|
|
65
|
+
// ─── Infisical ──────────────────────────────────────────────────────────────
|
|
66
|
+
function getInfisicalConfig() {
|
|
67
|
+
return {
|
|
68
|
+
url: getGitHubVariable('INFISICAL_URL'),
|
|
69
|
+
clientId: getGitHubVariable('INFISICAL_CLIENT_ID'),
|
|
70
|
+
clientSecret: getGitHubVariable('INFISICAL_CLIENT_SECRET'),
|
|
71
|
+
projectId: getGitHubVariable('INFISICAL_PROJECT_ID'),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function getInfisicalToken(config) {
|
|
75
|
+
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' });
|
|
76
|
+
return JSON.parse(response).accessToken;
|
|
77
|
+
}
|
|
78
|
+
function getInfisicalSecrets(config, token, environment, secretPath) {
|
|
79
|
+
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' });
|
|
80
|
+
const data = JSON.parse(response);
|
|
81
|
+
const secrets = {};
|
|
82
|
+
for (const secret of data.secrets || []) {
|
|
83
|
+
secrets[secret.secretKey] = secret.secretValue;
|
|
84
|
+
}
|
|
85
|
+
return secrets;
|
|
86
|
+
}
|
|
87
|
+
// ─── Database URL parsing ───────────────────────────────────────────────────
|
|
88
|
+
function parseDatabaseUrl(url) {
|
|
89
|
+
const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
|
|
90
|
+
if (!match)
|
|
91
|
+
throw new Error('Failed to parse DATABASE_URL (format: postgresql://user:pass@host:port/db)');
|
|
92
|
+
return { user: decodeURIComponent(match[1]), password: decodeURIComponent(match[2]), host: match[3], port: parseInt(match[4], 10), database: match[5] };
|
|
93
|
+
}
|
|
94
|
+
// ─── SSH tunnel ─────────────────────────────────────────────────────────────
|
|
95
|
+
function setupSSHTunnel(dbHost, localPort) {
|
|
96
|
+
try {
|
|
97
|
+
(0, child_process_1.execSync)(`lsof -ti:${localPort}`, { stdio: 'ignore' });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
catch { /* need tunnel */ }
|
|
101
|
+
const sshKeyPath = `${process.env.HOME}/.ssh/optima-ec2-key`;
|
|
102
|
+
if (!fs.existsSync(sshKeyPath))
|
|
103
|
+
throw new Error(`SSH key not found: ${sshKeyPath}. Please obtain optima-ec2-key from xbfool.`);
|
|
104
|
+
console.log(`Creating SSH tunnel: localhost:${localPort} -> ${EC2_HOST} -> ${dbHost}:5432`);
|
|
105
|
+
(0, child_process_1.execSync)(`ssh -i ${sshKeyPath} -f -N -o StrictHostKeyChecking=no -L ${localPort}:${dbHost}:5432 ec2-user@${EC2_HOST}`, { stdio: 'inherit' });
|
|
106
|
+
}
|
|
107
|
+
// ─── psql ───────────────────────────────────────────────────────────────────
|
|
108
|
+
function findPsqlPath() {
|
|
109
|
+
try {
|
|
110
|
+
const result = (0, child_process_1.execSync)('which psql', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
|
|
111
|
+
if (result.trim())
|
|
112
|
+
return result.trim();
|
|
113
|
+
}
|
|
114
|
+
catch { /* fallback */ }
|
|
115
|
+
const paths = ['/usr/local/opt/postgresql@16/bin/psql', '/usr/local/opt/postgresql@15/bin/psql', '/opt/homebrew/bin/psql', '/usr/bin/psql', '/usr/local/bin/psql'];
|
|
116
|
+
for (const p of paths) {
|
|
117
|
+
if (fs.existsSync(p))
|
|
118
|
+
return p;
|
|
119
|
+
}
|
|
120
|
+
throw new Error('PostgreSQL client (psql) not found. Install with: brew install postgresql@16');
|
|
121
|
+
}
|
|
122
|
+
function queryDB(conn, sql) {
|
|
123
|
+
const psql = findPsqlPath();
|
|
124
|
+
return (0, child_process_1.execSync)(`"${psql}" -h ${conn.host} -p ${conn.port} -U ${conn.user} -d ${conn.database} -t -A --quiet -c "${sql.replace(/"/g, '\\"')}"`, {
|
|
125
|
+
encoding: 'utf-8',
|
|
126
|
+
env: { ...process.env, PGPASSWORD: conn.password },
|
|
127
|
+
}).trim();
|
|
128
|
+
}
|
|
129
|
+
// ─── High-level connection helpers ──────────────────────────────────────────
|
|
130
|
+
/** Connect to user-auth DB and return a query function. */
|
|
131
|
+
async function connectAuthDB(env, infisicalConfig, token) {
|
|
132
|
+
const infisicalEnv = env === 'stage' ? 'staging' : 'prod';
|
|
133
|
+
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/shared-secrets/database-users');
|
|
134
|
+
const host = exports.RDS_HOSTS[env];
|
|
135
|
+
const port = env === 'stage' ? 15432 : 15433;
|
|
136
|
+
setupSSHTunnel(host, port);
|
|
137
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
138
|
+
const conn = { host: 'localhost', port, user: secrets['AUTH_DB_USER'], password: secrets['AUTH_DB_PASSWORD'], database: 'optima_auth' };
|
|
139
|
+
return { query: (sql) => queryDB(conn, sql) };
|
|
140
|
+
}
|
|
141
|
+
/** Connect to billing DB and return a query function. */
|
|
142
|
+
async function connectBillingDB(env, infisicalConfig, token) {
|
|
143
|
+
const infisicalEnv = env === 'stage' ? 'staging' : 'prod';
|
|
144
|
+
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/services/billing');
|
|
145
|
+
const dbUrl = secrets['DATABASE_URL'];
|
|
146
|
+
if (!dbUrl)
|
|
147
|
+
throw new Error('DATABASE_URL not found for billing service');
|
|
148
|
+
const parsed = parseDatabaseUrl(dbUrl);
|
|
149
|
+
const port = env === 'stage' ? 15434 : 15435;
|
|
150
|
+
setupSSHTunnel(parsed.host, port);
|
|
151
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
152
|
+
const conn = { host: 'localhost', port, user: parsed.user, password: parsed.password, database: parsed.database };
|
|
153
|
+
return { query: (sql) => queryDB(conn, sql) };
|
|
154
|
+
}
|
|
155
|
+
/** Look up user_id by email from user-auth DB. Throws if not found. */
|
|
156
|
+
async function resolveUserId(email, env, infisicalConfig, token) {
|
|
157
|
+
console.log(`Looking up user by email: ${email}`);
|
|
158
|
+
const auth = await connectAuthDB(env, infisicalConfig, token);
|
|
159
|
+
const userId = auth.query(`SELECT id FROM users WHERE email='${escapeSQL(email)}' LIMIT 1`);
|
|
160
|
+
if (!userId) {
|
|
161
|
+
console.error(`❌ User not found: ${email}`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
console.log(`✓ Found user: ${userId}`);
|
|
165
|
+
return userId;
|
|
166
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const db_utils_1 = require("./db-utils");
|
|
5
|
+
function parseArgs(args) {
|
|
6
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
7
|
+
console.log(`Usage: optima-grant-credits <email> --amount <n> [options]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--amount <n> Credits to grant (required)
|
|
11
|
+
--type <type> Credit type: bonus, referral (default: bonus)
|
|
12
|
+
--description <text> Description (optional)
|
|
13
|
+
--env <env> Environment: stage, prod (default: stage)
|
|
14
|
+
-h, --help Show this help`);
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
const email = args[0];
|
|
18
|
+
let amount = 0;
|
|
19
|
+
let type = 'bonus';
|
|
20
|
+
let description = null;
|
|
21
|
+
let env = 'stage';
|
|
22
|
+
for (let i = 1; i < args.length; i++) {
|
|
23
|
+
if (args[i] === '--amount' && args[i + 1]) {
|
|
24
|
+
amount = parseInt(args[++i], 10);
|
|
25
|
+
}
|
|
26
|
+
else if (args[i] === '--type' && args[i + 1]) {
|
|
27
|
+
type = args[++i];
|
|
28
|
+
}
|
|
29
|
+
else if (args[i] === '--description' && args[i + 1]) {
|
|
30
|
+
description = args[++i];
|
|
31
|
+
}
|
|
32
|
+
else if (args[i] === '--env' && args[i + 1]) {
|
|
33
|
+
env = args[++i];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (amount < 1) {
|
|
37
|
+
console.error('--amount is required and must be >= 1');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
if (!['bonus', 'referral'].includes(type)) {
|
|
41
|
+
console.error(`Unknown type: ${type}. Available: bonus, referral`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
if (!['stage', 'prod'].includes(env)) {
|
|
45
|
+
console.error('Env must be stage or prod (billing DB not available in CI)');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
return { email, amount, type, description, env };
|
|
49
|
+
}
|
|
50
|
+
async function main() {
|
|
51
|
+
const { email, amount, type, description, env } = parseArgs(process.argv.slice(2));
|
|
52
|
+
const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
|
|
53
|
+
const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
|
|
54
|
+
console.log(`\n🎁 Granting ${amount} ${type} credits to ${email} [${env.toUpperCase()}]\n`);
|
|
55
|
+
const userId = await (0, db_utils_1.resolveUserId)(email, env, infisicalConfig, token);
|
|
56
|
+
const billing = await (0, db_utils_1.connectBillingDB)(env, infisicalConfig, token);
|
|
57
|
+
const bq = billing.query;
|
|
58
|
+
const now = new Date().toISOString();
|
|
59
|
+
const safeUserId = (0, db_utils_1.escapeSQL)(userId);
|
|
60
|
+
const safeType = (0, db_utils_1.escapeSQL)(type);
|
|
61
|
+
const safeDesc = (0, db_utils_1.escapeSQL)(description || `Admin ${type} credit grant`);
|
|
62
|
+
console.log(`Inserting ${amount} ${type} credits...`);
|
|
63
|
+
const ledgerId = bq(`INSERT INTO credit_ledger (id, user_id, type, description, initial_amount, remaining, created_at) VALUES (concat('crd_${safeType}_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safeType}', '${safeDesc}', ${amount}, ${amount}, '${now}') RETURNING id`);
|
|
64
|
+
console.log(`✓ Credits granted (ledger ID: ${ledgerId})`);
|
|
65
|
+
const balance = bq(`SELECT COALESCE(SUM(remaining), 0) FROM credit_ledger WHERE user_id='${safeUserId}' AND remaining > 0 AND (expires_at IS NULL OR expires_at > NOW())`);
|
|
66
|
+
console.log(`\n✅ Done! ${email} now has ${balance} total credits\n`);
|
|
67
|
+
}
|
|
68
|
+
main().catch(error => {
|
|
69
|
+
console.error('\n❌ Error:', error.message);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const db_utils_1 = require("./db-utils");
|
|
5
|
+
function parseArgs(args) {
|
|
6
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
7
|
+
console.log(`Usage: optima-grant-subscription <email> [options]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--plan <id> Plan: free, pro, enterprise (default: enterprise)
|
|
11
|
+
--months <n> Duration in months (default: 1)
|
|
12
|
+
--env <env> Environment: stage, prod (default: stage)
|
|
13
|
+
-h, --help Show this help`);
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
const email = args[0];
|
|
17
|
+
let plan = 'enterprise';
|
|
18
|
+
let months = 1;
|
|
19
|
+
let env = 'stage';
|
|
20
|
+
for (let i = 1; i < args.length; i++) {
|
|
21
|
+
if (args[i] === '--plan' && args[i + 1]) {
|
|
22
|
+
plan = args[++i];
|
|
23
|
+
}
|
|
24
|
+
else if (args[i] === '--months' && args[i + 1]) {
|
|
25
|
+
months = parseInt(args[++i], 10);
|
|
26
|
+
}
|
|
27
|
+
else if (args[i] === '--env' && args[i + 1]) {
|
|
28
|
+
env = args[++i];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!['free', 'pro', 'enterprise'].includes(plan)) {
|
|
32
|
+
console.error(`Unknown plan: ${plan}. Available: free, pro, enterprise`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
if (months < 1) {
|
|
36
|
+
console.error('Months must be >= 1');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
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
|
+
return { email, plan, months, env };
|
|
44
|
+
}
|
|
45
|
+
async function main() {
|
|
46
|
+
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
|
+
console.log(`\n🎁 Granting ${plan} subscription to ${email} for ${months} month(s) [${env.toUpperCase()}]\n`);
|
|
50
|
+
const userId = await (0, db_utils_1.resolveUserId)(email, env, infisicalConfig, token);
|
|
51
|
+
const billing = await (0, db_utils_1.connectBillingDB)(env, infisicalConfig, token);
|
|
52
|
+
const bq = billing.query;
|
|
53
|
+
// Read plan config from DB
|
|
54
|
+
console.log(`Loading plan config: ${plan}`);
|
|
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);
|
|
59
|
+
}
|
|
60
|
+
const [planName, monthlyCreditsStr, sessionTokenLimitStr, weeklyTokenLimitStr] = planRow.split('|');
|
|
61
|
+
const monthlyCredits = parseInt(monthlyCreditsStr, 10);
|
|
62
|
+
const sessionTokenLimit = parseInt(sessionTokenLimitStr, 10);
|
|
63
|
+
const weeklyTokenLimit = parseInt(weeklyTokenLimitStr, 10);
|
|
64
|
+
console.log(`✓ Plan: ${planName} (credits: ${monthlyCredits}, session: ${sessionTokenLimit.toLocaleString()}, weekly: ${weeklyTokenLimit.toLocaleString()})`);
|
|
65
|
+
// Execute all mutations in a single transaction
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
const periodEnd = new Date();
|
|
68
|
+
periodEnd.setMonth(periodEnd.getMonth() + months);
|
|
69
|
+
const periodEndISO = periodEnd.toISOString();
|
|
70
|
+
const sessionEnd = new Date(new Date().getTime() + 5 * 60 * 60 * 1000).toISOString();
|
|
71
|
+
const weekEnd = new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
72
|
+
const safeUserId = (0, db_utils_1.escapeSQL)(userId);
|
|
73
|
+
const safePlan = (0, db_utils_1.escapeSQL)(plan);
|
|
74
|
+
const safePlanName = (0, db_utils_1.escapeSQL)(planName);
|
|
75
|
+
console.log('Executing transaction...');
|
|
76
|
+
const txSQL = `
|
|
77
|
+
BEGIN;
|
|
78
|
+
|
|
79
|
+
-- Cancel active subscriptions
|
|
80
|
+
UPDATE subscriptions SET status='canceled', canceled_at='${now}'
|
|
81
|
+
WHERE user_id='${safeUserId}' AND status IN ('active','trialing');
|
|
82
|
+
|
|
83
|
+
-- Zero out old subscription credits
|
|
84
|
+
UPDATE credit_ledger SET remaining=0
|
|
85
|
+
WHERE user_id='${safeUserId}' AND type IN ('monthly_grant','subscription') AND remaining > 0;
|
|
86
|
+
|
|
87
|
+
-- Create new subscription
|
|
88
|
+
INSERT INTO subscriptions (id, user_id, plan_id, status, billing_interval, current_period_start, current_period_end, created_at, updated_at)
|
|
89
|
+
VALUES (concat('sub_gift_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'active', 'monthly', '${now}', '${periodEndISO}', '${now}', '${now}');
|
|
90
|
+
|
|
91
|
+
-- Grant monthly credits
|
|
92
|
+
INSERT INTO credit_ledger (id, user_id, type, description, initial_amount, remaining, expires_at, created_at)
|
|
93
|
+
SELECT concat('crd_gift_', substr(md5(random()::text), 1, 16)), '${safeUserId}', 'subscription', '${safePlanName} plan gift (${months} month)', ${monthlyCredits}, ${monthlyCredits}, '${periodEndISO}', '${now}'
|
|
94
|
+
WHERE ${monthlyCredits} > 0;
|
|
95
|
+
|
|
96
|
+
-- Upsert session token quota
|
|
97
|
+
INSERT INTO token_quotas (id, user_id, plan_id, period_type, monthly_limit, monthly_used, period_start, period_end, created_at, updated_at)
|
|
98
|
+
VALUES (concat('tq_sess_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'session', ${sessionTokenLimit}, 0, '${now}', '${sessionEnd}', '${now}', '${now}')
|
|
99
|
+
ON CONFLICT (user_id, period_type, period_start) DO UPDATE SET plan_id='${safePlan}', monthly_limit=${sessionTokenLimit}, updated_at='${now}';
|
|
100
|
+
|
|
101
|
+
-- Upsert weekly token quota
|
|
102
|
+
INSERT INTO token_quotas (id, user_id, plan_id, period_type, monthly_limit, monthly_used, period_start, period_end, created_at, updated_at)
|
|
103
|
+
VALUES (concat('tq_week_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'weekly', ${weeklyTokenLimit}, 0, '${now}', '${weekEnd}', '${now}', '${now}')
|
|
104
|
+
ON CONFLICT (user_id, period_type, period_start) DO UPDATE SET plan_id='${safePlan}', monthly_limit=${weeklyTokenLimit}, updated_at='${now}';
|
|
105
|
+
|
|
106
|
+
COMMIT;
|
|
107
|
+
`.trim();
|
|
108
|
+
bq(txSQL);
|
|
109
|
+
console.log('✓ Old subscriptions canceled');
|
|
110
|
+
console.log('✓ Old credits cleared');
|
|
111
|
+
console.log(`✓ ${planName} subscription created (expires: ${periodEnd.toLocaleDateString()})`);
|
|
112
|
+
if (monthlyCredits > 0) {
|
|
113
|
+
console.log(`✓ ${monthlyCredits} credits granted`);
|
|
114
|
+
}
|
|
115
|
+
console.log(`✓ Token quotas updated (session: ${sessionTokenLimit.toLocaleString()}, weekly: ${weeklyTokenLimit.toLocaleString()})`);
|
|
116
|
+
console.log(`\n✅ Done! ${email} now has ${planName} plan until ${periodEnd.toLocaleDateString()}\n`);
|
|
117
|
+
}
|
|
118
|
+
main().catch(error => {
|
|
119
|
+
console.error('\n❌ Error:', error.message);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
});
|
|
@@ -66,6 +66,21 @@ const SERVICE_DB_MAP = {
|
|
|
66
66
|
ci: null,
|
|
67
67
|
stage: { userKey: 'LOGISTICS_DB_USER', passwordKey: 'LOGISTICS_DB_PASSWORD', database: 'optima_stage_logistics' },
|
|
68
68
|
prod: { userKey: 'LOGISTICS_DB_USER', passwordKey: 'LOGISTICS_DB_PASSWORD', database: 'optima_logistics' }
|
|
69
|
+
},
|
|
70
|
+
'billing': {
|
|
71
|
+
ci: null,
|
|
72
|
+
stage: { databaseUrlPath: '/services/billing', databaseUrlKey: 'DATABASE_URL' },
|
|
73
|
+
prod: { databaseUrlPath: '/services/billing', databaseUrlKey: 'DATABASE_URL' }
|
|
74
|
+
},
|
|
75
|
+
'browser-backend': {
|
|
76
|
+
ci: null,
|
|
77
|
+
stage: { databaseUrlPath: '/services/browser-backend', databaseUrlKey: 'DATABASE_URL' },
|
|
78
|
+
prod: { databaseUrlPath: '/services/browser-backend', databaseUrlKey: 'DATABASE_URL' }
|
|
79
|
+
},
|
|
80
|
+
'optima-generation': {
|
|
81
|
+
ci: null,
|
|
82
|
+
stage: { databaseUrlPath: '/services/optima-generation', databaseUrlKey: 'DATABASE_URL' },
|
|
83
|
+
prod: { databaseUrlPath: '/services/optima-generation', databaseUrlKey: 'DATABASE_URL' }
|
|
69
84
|
}
|
|
70
85
|
};
|
|
71
86
|
// Stage 和 Prod 独立的 RDS 实例
|
|
@@ -75,6 +90,20 @@ const RDS_HOSTS = {
|
|
|
75
90
|
};
|
|
76
91
|
// 统一使用 BI Data ARM Host 作为跳板机
|
|
77
92
|
const EC2_HOST = '3.0.210.113';
|
|
93
|
+
function parseDatabaseUrl(url) {
|
|
94
|
+
// postgresql://user:password@host:port/database?params
|
|
95
|
+
const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
|
|
96
|
+
if (!match) {
|
|
97
|
+
throw new Error(`Failed to parse DATABASE_URL: ${url}`);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
user: decodeURIComponent(match[1]),
|
|
101
|
+
password: decodeURIComponent(match[2]),
|
|
102
|
+
host: match[3],
|
|
103
|
+
port: parseInt(match[4]),
|
|
104
|
+
database: match[5]
|
|
105
|
+
};
|
|
106
|
+
}
|
|
78
107
|
function getGitHubVariable(name) {
|
|
79
108
|
return (0, child_process_1.execSync)(`gh variable get ${name} -R Optima-Chat/optima-dev-skills`, { encoding: 'utf-8' }).trim();
|
|
80
109
|
}
|
|
@@ -170,7 +199,7 @@ async function main() {
|
|
|
170
199
|
if (args.length < 2) {
|
|
171
200
|
console.error('Usage: query-db.ts <service> <sql> [environment]');
|
|
172
201
|
console.error('');
|
|
173
|
-
console.error('Services: commerce-backend, user-auth, agentic-chat, bi-backend, session-gateway');
|
|
202
|
+
console.error('Services: commerce-backend, user-auth, agentic-chat, bi-backend, session-gateway, optima-logistics, billing, browser-backend, optima-generation');
|
|
174
203
|
console.error('Environments: ci (default), stage, prod');
|
|
175
204
|
console.error('');
|
|
176
205
|
console.error('Example: query-db.ts user-auth "SELECT COUNT(*) FROM users" prod');
|
|
@@ -206,17 +235,38 @@ async function main() {
|
|
|
206
235
|
console.log('✓ Loaded Infisical config from GitHub Variables');
|
|
207
236
|
const token = getInfisicalToken(infisicalConfig);
|
|
208
237
|
console.log('✓ Obtained Infisical access token');
|
|
209
|
-
// 数据库凭证存储在 Infisical 的 /shared-secrets/database-users 路径
|
|
210
|
-
// Stage 从 staging 环境读取,Prod 从 prod 环境读取
|
|
211
238
|
const infisicalEnv = environment === 'stage' ? 'staging' : 'prod';
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
239
|
+
let dbUser;
|
|
240
|
+
let dbPassword;
|
|
241
|
+
let dbHost;
|
|
242
|
+
let database;
|
|
243
|
+
if ('databaseUrlPath' in serviceConfig) {
|
|
244
|
+
// 从服务路径获取 DATABASE_URL 并解析
|
|
245
|
+
const { databaseUrlPath, databaseUrlKey } = serviceConfig;
|
|
246
|
+
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, databaseUrlPath);
|
|
247
|
+
console.log(`✓ Retrieved DATABASE_URL from Infisical (path: ${databaseUrlPath})`);
|
|
248
|
+
const databaseUrl = secrets[databaseUrlKey];
|
|
249
|
+
if (!databaseUrl) {
|
|
250
|
+
throw new Error(`DATABASE_URL not found in Infisical at ${databaseUrlPath}`);
|
|
251
|
+
}
|
|
252
|
+
const parsed = parseDatabaseUrl(databaseUrl);
|
|
253
|
+
dbUser = parsed.user;
|
|
254
|
+
dbPassword = parsed.password;
|
|
255
|
+
dbHost = parsed.host;
|
|
256
|
+
database = parsed.database;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
// 从 shared-secrets/database-users 获取凭证
|
|
260
|
+
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/shared-secrets/database-users');
|
|
261
|
+
console.log('✓ Retrieved database credentials from Infisical');
|
|
262
|
+
const { userKey, passwordKey } = serviceConfig;
|
|
263
|
+
database = serviceConfig.database;
|
|
264
|
+
dbHost = RDS_HOSTS[environment];
|
|
265
|
+
dbUser = secrets[userKey];
|
|
266
|
+
dbPassword = secrets[passwordKey];
|
|
267
|
+
if (!dbUser || !dbPassword) {
|
|
268
|
+
throw new Error(`Database credentials not found in Infisical for ${service}. Keys: ${userKey}, ${passwordKey}`);
|
|
269
|
+
}
|
|
220
270
|
}
|
|
221
271
|
const localPort = environment === 'stage' ? 15432 : 15433;
|
|
222
272
|
setupSSHTunnel(EC2_HOST, dbHost, localPort);
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optima-chat/dev-skills",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.22",
|
|
4
4
|
"description": "Claude Code Skills for Optima development team - cross-environment collaboration tools",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"optima-dev-skills": "./bin/cli.js",
|
|
8
8
|
"optima-query-db": "./dist/bin/helpers/query-db.js",
|
|
9
9
|
"optima-generate-test-token": "./dist/bin/helpers/generate-test-token.js",
|
|
10
|
-
"optima-show-env": "./dist/bin/helpers/show-env.js"
|
|
10
|
+
"optima-show-env": "./dist/bin/helpers/show-env.js",
|
|
11
|
+
"optima-grant-subscription": "./dist/bin/helpers/grant-subscription.js",
|
|
12
|
+
"optima-grant-credits": "./dist/bin/helpers/grant-credits.js"
|
|
11
13
|
},
|
|
12
14
|
"scripts": {
|
|
13
15
|
"postinstall": "node scripts/install.js",
|