@optima-chat/dev-skills 0.15.0 → 0.16.1
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/skills/gateway-admin/SKILL.md +38 -0
- package/.claude/skills/query-db/SKILL.md +2 -0
- package/.claude/skills/show-env/SKILL.md +15 -2
- package/bin/cli.js +1 -0
- package/bin/helpers/billing-http.ts +10 -4
- package/bin/helpers/cn-deploy.ts +4 -1
- package/bin/helpers/db-utils.ts +65 -3
- package/bin/helpers/gateway-admin.ts +186 -0
- package/dist/bin/helpers/billing-http.js +11 -5
- package/dist/bin/helpers/cn-deploy.js +4 -1
- package/dist/bin/helpers/db-utils.js +79 -3
- package/dist/bin/helpers/gateway-admin.js +202 -0
- package/package.json +3 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gateway-admin
|
|
3
|
+
description: 当用户请求操作 gateway 管理面时使用——COO kill/软杀某用户的协调官、查看或调整 warm-pool、credits adjust、provider key 管理、gateway config/llm-rates/models 的读写,或任何 gateway-core /admin/* 端点调用。支持 cn-stage、cn-prod 两个环境。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Gateway Admin — gateway-core /admin/* 操作
|
|
7
|
+
|
|
8
|
+
当用户要求「杀掉某用户的 COO / 软杀容器 / drain warm pool / 查 gateway 配置 / 调 llm 费率 / 管理 provider key」等 gateway 管理面操作时,使用 `optima-gateway-admin` CLI。
|
|
9
|
+
|
|
10
|
+
## 用法
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
optima-gateway-admin <METHOD> </admin/...> [jsonBody] [--env cn-stage|cn-prod] [--yes]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- 默认环境 `cn-stage`;`cn-prod` 是生产,谨慎。
|
|
17
|
+
- GET/HEAD 直通;**POST/PUT/PATCH/DELETE 会回显完整请求并要求输入 yes 确认**(所有环境,不只 prod)。脚本化传 `--yes`。
|
|
18
|
+
- path 必须以 `/admin/` 开头(硬约束,防 token 误用于其它面)。
|
|
19
|
+
- 凭证自动走 dev-skills OAuth client(client_credentials,scope=gateway:admin,#70 方向 1);cn-stage 需要 `INFISICAL_CN_EMAIL/PASSWORD` 环境变量(与 query-db cn 同一前提)。
|
|
20
|
+
|
|
21
|
+
## 常用例子
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# 查 llm 费率表
|
|
25
|
+
optima-gateway-admin GET /admin/llm-rates --env cn-stage
|
|
26
|
+
|
|
27
|
+
# 软杀某用户的 COO(#1681 止血这类场景——不再手写 DB UPDATE)
|
|
28
|
+
optima-gateway-admin POST /admin/coo/users/<userId>/kill --env cn-stage
|
|
29
|
+
|
|
30
|
+
# 改 gateway 动态配置
|
|
31
|
+
optima-gateway-admin PUT /admin/config/<KEY> '{"value":"..."}' --env cn-stage
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 注意
|
|
35
|
+
|
|
36
|
+
- 403 且提示 scope 为空 → 该环境 dev-skills client 的 `allowed_scopes` 缺 `gateway:admin`(见 optima-dev-skills#70)。
|
|
37
|
+
- 写操作打的是运行中的 gateway 管理面,stage 上也可能打断在跑会话——确认提示不要盲敲 yes。
|
|
38
|
+
- AWS stage 未接(AWS prod gateway 已关停);需要时见 #70。
|
|
@@ -212,6 +212,8 @@ optima-query-db gateway-core "SELECT key, value FROM app_configs" cn-prod
|
|
|
212
212
|
| `INFISICAL_CN_EMAIL` + `INFISICAL_CN_PASSWORD` | 登录 cn Infisical 取数据库凭证 | 1Password「Infisical cn-prod admin (secrets-cn.optima.chat)」 |
|
|
213
213
|
| `OPTIMA_CN_BUILDBOX_PASSWORD` | 经 buildbox 跳板建 RDS 隧道(本机已有健康隧道时可省) | 1Password「Aliyun cn-prod buildbox ECS (root)」 |
|
|
214
214
|
|
|
215
|
+
> **两条独立路径**:① 从上表的 1P 取值 `export` 到当前 session(不落盘,推荐;有 `op` CLI 更佳);或 ② 若你**自己**维护本地明文文件 `~/.infisical_cn_creds`(每行 `export KEY=val`)和 `~/.buildbox_pw`(纯密码一行),工具会自动读取、免手动 `export`/`source`(路径分别可用 `INFISICAL_CN_CREDS_FILE` / `OPTIMA_CN_BUILDBOX_PW_FILE` 覆盖)。②是明文落盘的便捷取舍(`chmod 600`,权限宽于 600 工具会打警告),非默认做法、别把 1P 密码抄进文件;没放这些文件时回退走 env(①)。creds 文件解析**不是 shell**:只认最朴素的 `KEY=值` 与成对引号,无引号值别写行尾注释。
|
|
216
|
+
|
|
215
217
|
## 🔧 技术架构
|
|
216
218
|
|
|
217
219
|
### Infisical 配置(v0.7.0+)
|
|
@@ -77,8 +77,10 @@ optima-show-env agentic-chat stage
|
|
|
77
77
|
- `shopify-backend` - Shopify 店铺管理服务
|
|
78
78
|
|
|
79
79
|
**支持的环境**:
|
|
80
|
-
- `stage` - Stage
|
|
81
|
-
- `prod` - Prod
|
|
80
|
+
- `stage` - Stage 预发布环境(AWS Infisical)
|
|
81
|
+
- `prod` - Prod 生产环境(AWS Infisical)
|
|
82
|
+
- `cn-stage` - 阿里云 cn-stage(读 cn Infisical,需凭据,见「故障排查 · 问题 2b」)
|
|
83
|
+
- `cn-prod` - 阿里云 cn-prod(同上)
|
|
82
84
|
|
|
83
85
|
### 3. 使用过滤和选项
|
|
84
86
|
|
|
@@ -198,6 +200,17 @@ npm install -g @optima-chat/dev-skills@latest
|
|
|
198
200
|
- 运行 `gh auth login` 登录 GitHub
|
|
199
201
|
- 确认有仓库访问权限
|
|
200
202
|
|
|
203
|
+
### 问题 2b:cn-stage / cn-prod 报「需要 INFISICAL_CN_EMAIL / PASSWORD」
|
|
204
|
+
|
|
205
|
+
`cn-stage` / `cn-prod` 读的是**阿里云 cn Infisical**(`secrets-cn.optima.chat`,与 AWS 那套完全独立),需要 admin 账密两个环境变量。**两条独立路径,按你的安全取向二选一**:
|
|
206
|
+
|
|
207
|
+
- **走 1Password(推荐,密码不落盘)**:从「Infisical cn-prod admin (secrets-cn.optima.chat)」取值,`export INFISICAL_CN_EMAIL=... INFISICAL_CN_PASSWORD=...` 到当前 session(装了 `op` CLI 可 `op run` / `op read` 注入,用完即散)。
|
|
208
|
+
- **本地凭据文件(便捷,安全性较弱)**:若你**自己**维护 `~/.infisical_cn_creds`(每行 `export KEY=val`),本工具会自动读取、免手动 `source`;路径用 `INFISICAL_CN_CREDS_FILE` 覆盖。⚠️ 明文落盘(务必 `chmod 600`),是「省事 vs 暴露面」的取舍——**不是让你把 1P 密码抄进文件当默认做法**,只是给已经这么存的人一个自动读取入口。
|
|
209
|
+
|
|
210
|
+
> 两者互不依赖:有本地文件就自动用,没有就回退读 env(即走 1P 那条)。`optima-query-db cn-stage/cn-prod` 的 buildbox 密码同理——`export OPTIMA_CN_BUILDBOX_PASSWORD=...`,或本地 `~/.buildbox_pw`(纯密码一行,自动读取;`OPTIMA_CN_BUILDBOX_PW_FILE` 可覆盖路径);密码在 1P「Aliyun cn-prod buildbox ECS (root)」。
|
|
211
|
+
>
|
|
212
|
+
> ⚠️ creds 文件的解析**不是 shell**:只认每行一条最朴素的 `KEY=值`(可带 `export `),值要么整体用成对引号包住、要么不含空格;引号值后的内容(如行尾注释)会被忽略,**无引号值别写行尾注释**(`#` 会被当成值的一部分)。文件权限宽于 600 时工具会打警告。
|
|
213
|
+
|
|
201
214
|
### 问题 3:服务不存在
|
|
202
215
|
|
|
203
216
|
**错误信息**: "Unknown service 'xxx'"
|
package/bin/cli.js
CHANGED
|
@@ -37,6 +37,7 @@ switch (command) {
|
|
|
37
37
|
log(' optima-grant-credits <email|phone|userId> --credits <n> [--env] Grant credits (bonus, 30d)', 'cyan');
|
|
38
38
|
log(' optima-grant-subscription <email|phone|userId> --plan <p> [--env] Grant subscription', 'cyan');
|
|
39
39
|
log(' optima-logs <service> [--env] [--since] [--grep] [-n] View logs (cn=SLS 直连/aws=CloudWatch)', 'cyan');
|
|
40
|
+
log(' optima-gateway-admin <METHOD> </admin/path> [body] [--env cn-stage|cn-prod] Gateway /admin/* passthrough (#70)', 'cyan');
|
|
40
41
|
log(' /restart-ecs <service> [env] Restart ECS service (skill)', 'cyan');
|
|
41
42
|
|
|
42
43
|
log('\nSupported Services:', 'yellow');
|
|
@@ -107,8 +107,13 @@ function getSkillsUrl(env: string): string {
|
|
|
107
107
|
return url;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
export function getServiceToken(env: string): string {
|
|
111
|
-
|
|
110
|
+
export function getServiceToken(env: string, scope?: string): string {
|
|
111
|
+
// Cache key includes scope: the same env can mint tokens with different
|
|
112
|
+
// scopes (default internal:users:write vs gateway:admin for gateway-admin,
|
|
113
|
+
// #70) and user-auth issues request∩allowed_scopes — mixing them up would
|
|
114
|
+
// hand a wrongly-scoped token to the caller.
|
|
115
|
+
const cacheKey = scope ? `${env}:${scope}` : env;
|
|
116
|
+
if (tokenCache[cacheKey]) return tokenCache[cacheKey];
|
|
112
117
|
|
|
113
118
|
// cn-prod 与 cn-stage 都用 client_credentials M2M token,scope=internal:users:write。
|
|
114
119
|
// 凭证来源不同:
|
|
@@ -141,7 +146,8 @@ export function getServiceToken(env: string): string {
|
|
|
141
146
|
const authUrl = USER_AUTH_URLS[env];
|
|
142
147
|
if (!authUrl) throw new Error(`Unknown env: ${env}`);
|
|
143
148
|
|
|
144
|
-
const
|
|
149
|
+
const effectiveScope = scope ?? (isCn ? CN_PROD_TOKEN_SCOPE : undefined);
|
|
150
|
+
const scopeParam = effectiveScope ? `&scope=${encodeURIComponent(effectiveScope)}` : '';
|
|
145
151
|
const body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}${scopeParam}`;
|
|
146
152
|
const response = execSync(
|
|
147
153
|
`curl -s -X POST '${authUrl}/api/v1/oauth/token' -H 'Content-Type: application/x-www-form-urlencoded' -d '${body}'`,
|
|
@@ -157,7 +163,7 @@ export function getServiceToken(env: string): string {
|
|
|
157
163
|
if (!parsed.access_token) {
|
|
158
164
|
throw new Error(`user-auth token mint failed (${env}): ${response.slice(0, 200)}`);
|
|
159
165
|
}
|
|
160
|
-
tokenCache[
|
|
166
|
+
tokenCache[cacheKey] = parsed.access_token;
|
|
161
167
|
return parsed.access_token;
|
|
162
168
|
}
|
|
163
169
|
|
package/bin/helpers/cn-deploy.ts
CHANGED
|
@@ -120,7 +120,10 @@ async function main() {
|
|
|
120
120
|
devops('TriggerRepositoryMirrorSync', { repositoryId: String(repoId), account: 'xbfool', token: ghTok });
|
|
121
121
|
let synced = false;
|
|
122
122
|
for (let i = 0; i < 30; i++) {
|
|
123
|
-
|
|
123
|
+
// vtag 发版时 ref 是 tag,不是 branch —— GetBranchInfo 查不到 tag 会恒超时,改用 tag 接口。
|
|
124
|
+
const b = vtag
|
|
125
|
+
? devops('GetRepositoryTag', { repositoryId: String(repoId), tagName: ref })
|
|
126
|
+
: devops('GetBranchInfo', { repositoryId: String(repoId), branchName: ref });
|
|
124
127
|
if (b?.result?.commit?.id === ghSha) { synced = true; break; }
|
|
125
128
|
await sleep(10000);
|
|
126
129
|
}
|
package/bin/helpers/db-utils.ts
CHANGED
|
@@ -109,15 +109,75 @@ function curlJson(args: string[]): any {
|
|
|
109
109
|
catch { throw new Error(`cn Infisical: non-JSON response: ${String(out).slice(0, 200)}`); }
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/** 明文密码文件权限比 600 宽(组/其他用户可读)时往 stderr 警告一行。Windows 无此语义,跳过。 */
|
|
113
|
+
function warnIfLoosePerms(file: string): void {
|
|
114
|
+
if (process.platform === 'win32') return;
|
|
115
|
+
try {
|
|
116
|
+
const mode = fs.statSync(file).mode & 0o777;
|
|
117
|
+
if (mode & 0o077) console.error(`⚠️ ${file} 权限 ${mode.toString(8).padStart(3, '0')}(同机其他用户可读)——建议 chmod 600 ${file}`);
|
|
118
|
+
} catch { /* stat 失败不阻塞主流程 */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 若 creds 文件存在,把其中 `export KEY=val` / `KEY=val` 行注入 process.env(已存在的键不覆盖)。
|
|
123
|
+
* 免去每次手动 `source ~/.infisical_cn_creds`;文件不存在则静默跳过,回退到调用方的 env 检查。
|
|
124
|
+
* ⚠️ 不是 shell:只认最朴素的每行一条 `KEY=值`。值以引号开头时取到配对引号为止(其后内容
|
|
125
|
+
* 忽略,行尾注释因此无害);引号未闭合的行警告并跳过(真 shell 会语法错,静默注入 `"abc`
|
|
126
|
+
* 这类错值只会换来下游 401、更难查);无引号值取到行尾原样保留(别写行尾注释,`#` 会进值)。
|
|
127
|
+
* 导出仅供测试。
|
|
128
|
+
*/
|
|
129
|
+
const loadedCredsFiles = new Set<string>(); // 同一文件只加载/警告一次(getCnInfisicalToken 与 setupCnSSHTunnel 都会调)
|
|
130
|
+
export function loadCredsFileIntoEnv(file: string): void {
|
|
131
|
+
if (loadedCredsFiles.has(file)) return;
|
|
132
|
+
loadedCredsFiles.add(file);
|
|
133
|
+
let content: string;
|
|
134
|
+
try { content = fs.readFileSync(file, 'utf-8'); }
|
|
135
|
+
catch { return; } // 文件不存在/不可读 = 无操作,保持原有「读 env」行为
|
|
136
|
+
warnIfLoosePerms(file);
|
|
137
|
+
const lines = content.split(/\r?\n/);
|
|
138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
139
|
+
const m = lines[i].match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
140
|
+
if (!m) continue;
|
|
141
|
+
const key = m[1];
|
|
142
|
+
if (process.env[key] !== undefined) continue; // 已有 env 优先,不覆盖
|
|
143
|
+
let val = m[2].trim();
|
|
144
|
+
const q = val[0];
|
|
145
|
+
if (q === '"' || q === "'") {
|
|
146
|
+
const close = val.indexOf(q, 1);
|
|
147
|
+
if (close === -1) {
|
|
148
|
+
console.error(`⚠️ ${file} 第 ${i + 1} 行(${key}=)引号未闭合,已跳过该行`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
val = val.slice(1, close); // 配对引号截取,忽略其后(如行尾注释)
|
|
152
|
+
}
|
|
153
|
+
process.env[key] = val;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 读 buildbox 密码文件(纯密码一行;默认 ~/.buildbox_pw,OPTIMA_CN_BUILDBOX_PW_FILE 可覆盖)。
|
|
159
|
+
* 文件不存在/为空 → undefined,回退到调用方报错。导出仅供测试。
|
|
160
|
+
*/
|
|
161
|
+
export function readBuildboxPwFile(): string | undefined {
|
|
162
|
+
const file = process.env.OPTIMA_CN_BUILDBOX_PW_FILE || `${os.homedir()}/.buildbox_pw`;
|
|
163
|
+
let pw: string;
|
|
164
|
+
try { pw = fs.readFileSync(file, 'utf-8').trim(); }
|
|
165
|
+
catch { return undefined; }
|
|
166
|
+
warnIfLoosePerms(file); // 读到文件就查权限,空文件也警告
|
|
167
|
+
if (!pw) return undefined;
|
|
168
|
+
return pw;
|
|
169
|
+
}
|
|
170
|
+
|
|
112
171
|
/**
|
|
113
172
|
* 认证 cn Infisical(admin email/password → org-scoped token)。
|
|
114
173
|
* ⚠️ 字段名坑:login 返回 `accessToken`(不是 token),select-organization 才返回 `token`。
|
|
115
174
|
*/
|
|
116
175
|
export function getCnInfisicalToken(): string {
|
|
176
|
+
loadCredsFileIntoEnv(process.env.INFISICAL_CN_CREDS_FILE || `${os.homedir()}/.infisical_cn_creds`);
|
|
117
177
|
const email = process.env.INFISICAL_CN_EMAIL;
|
|
118
178
|
const password = process.env.INFISICAL_CN_PASSWORD;
|
|
119
179
|
if (!email || !password) {
|
|
120
|
-
throw new Error('cn Infisical 需要 INFISICAL_CN_EMAIL + INFISICAL_CN_PASSWORD
|
|
180
|
+
throw new Error('cn Infisical 需要 INFISICAL_CN_EMAIL + INFISICAL_CN_PASSWORD。① 从 1Password "Infisical cn-prod admin (secrets-cn.optima.chat)" 取值 export(不落盘);或 ② 本工具自动读取本地文件 ~/.infisical_cn_creds(每行 `export KEY=val`,INFISICAL_CN_CREDS_FILE 可覆盖)。见 optima-dev-skills#21。');
|
|
121
181
|
}
|
|
122
182
|
const login = curlJson([
|
|
123
183
|
'-X', 'POST', `${CN_INFISICAL_URL}/api/v3/auth/login`,
|
|
@@ -318,8 +378,10 @@ function setupSSMTunnel(dbHost: string, localPort: number): void {
|
|
|
318
378
|
* 密码经 SSHPASS 环境变量传给 `sshpass -e`:不进命令行(ps 不可见)、不经 shell 引号展开。
|
|
319
379
|
*/
|
|
320
380
|
function setupCnSSHTunnel(dbHost: string, localPort: number): void {
|
|
321
|
-
|
|
322
|
-
|
|
381
|
+
// creds 文件里也可能放 OPTIMA_CN_BUILDBOX_PASSWORD——自己加载一次(幂等),不依赖 getCnInfisicalToken 恰好先跑
|
|
382
|
+
loadCredsFileIntoEnv(process.env.INFISICAL_CN_CREDS_FILE || `${os.homedir()}/.infisical_cn_creds`);
|
|
383
|
+
const pw = process.env.OPTIMA_CN_BUILDBOX_PASSWORD || readBuildboxPwFile();
|
|
384
|
+
if (!pw) throw new Error('cn DB 隧道需要 OPTIMA_CN_BUILDBOX_PASSWORD。① 从 1Password "Aliyun cn-prod buildbox ECS (root)" 取值 export;或 ② 本工具自动读取本地文件 ~/.buildbox_pw(纯密码一行,OPTIMA_CN_BUILDBOX_PW_FILE 可覆盖路径)。见 optima-dev-skills#21。');
|
|
323
385
|
try { execSync('command -v sshpass', { stdio: 'ignore' }); }
|
|
324
386
|
catch { throw new Error('sshpass not found — cn buildbox 隧道需要(apt install sshpass / brew install esolitos/ipa/sshpass)。Windows 用 WSL。'); }
|
|
325
387
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// optima-gateway-admin — 通用透传型 gateway-core /admin/* 操作 CLI(#70)。
|
|
4
|
+
//
|
|
5
|
+
// 定位:dev-skills 现有 admin 技能(account/grant-credits/entitlement)打的都是
|
|
6
|
+
// user-auth 与 billing;gateway 自身的 /admin/*(COO kill、warm-pool drain、
|
|
7
|
+
// credits adjust、provider key CRUD、config CRUD、llm-rates、models 等)此前只能
|
|
8
|
+
// 手写 curl + 手拼 token(#1681 止血即被迫手写 DB UPDATE + aliyun CLI)。本命令
|
|
9
|
+
// 把「铸 gateway:admin token → 调端点 → 展示结果」收敛成一条命令。
|
|
10
|
+
//
|
|
11
|
+
// 凭证(#70 方向 1):复用 dev-skills 各环境既有 OAuth client(client_credentials),
|
|
12
|
+
// 其 allowed_scopes 已加 gateway:admin(cn-stage/cn-prod 2026-07-23 提权);
|
|
13
|
+
// token 铸造显式带 scope=gateway:admin(user-auth 发 request∩allowed_scopes,
|
|
14
|
+
// 不显式带则 scope 为空 → gateway 403,见 #70 技术现状)。
|
|
15
|
+
//
|
|
16
|
+
// 安全门:GET/HEAD 直通;写方法(POST/PUT/PATCH/DELETE)一律回显完整
|
|
17
|
+
// method+URL+body 后要求交互确认(所有环境,非仅 prod——admin 面写操作在
|
|
18
|
+
// stage 也可能打断在跑会话),--yes 跳过(脚本化用)。
|
|
19
|
+
//
|
|
20
|
+
// v1 支持 cn-stage / cn-prod(AWS prod gateway 已关停 2026-06-26;AWS stage
|
|
21
|
+
// 需要时再接 domain-urls,见 #70 讨论)。
|
|
22
|
+
|
|
23
|
+
import * as readline from 'readline';
|
|
24
|
+
import { getServiceToken } from './billing-http';
|
|
25
|
+
|
|
26
|
+
const GATEWAY_ADMIN_SCOPE = 'gateway:admin';
|
|
27
|
+
|
|
28
|
+
// 与 verify-health.ts 的 gateway-core 域名同源(cn 域名稳定、硬编码,与
|
|
29
|
+
// billing-http 的 cn URL 处理同一 rationale)。
|
|
30
|
+
const GATEWAY_URLS: Record<string, string> = {
|
|
31
|
+
'cn-prod': 'https://gw.yzsgo.com',
|
|
32
|
+
'cn-stage': 'https://gw.stage.optima.chat',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
36
|
+
|
|
37
|
+
interface Parsed {
|
|
38
|
+
method: string;
|
|
39
|
+
path: string;
|
|
40
|
+
body: string | null;
|
|
41
|
+
env: string;
|
|
42
|
+
yes: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function usage(): never {
|
|
46
|
+
console.log(`Usage: optima-gateway-admin <METHOD> <path> [jsonBody] [options]
|
|
47
|
+
|
|
48
|
+
Call a gateway-core /admin/* endpoint with a gateway:admin service token (#70).
|
|
49
|
+
|
|
50
|
+
Arguments:
|
|
51
|
+
METHOD GET | POST | PUT | PATCH | DELETE
|
|
52
|
+
path Endpoint path, must start with /admin/
|
|
53
|
+
jsonBody JSON request body (write methods only)
|
|
54
|
+
|
|
55
|
+
Options:
|
|
56
|
+
--env <env> cn-stage (default) | cn-prod
|
|
57
|
+
--yes Skip the write-confirmation prompt (scripting)
|
|
58
|
+
-h, --help Show this help
|
|
59
|
+
|
|
60
|
+
Safety:
|
|
61
|
+
GET/HEAD run directly. POST/PUT/PATCH/DELETE echo the full request and
|
|
62
|
+
require typing "yes" — on EVERY env, not just prod (admin writes can
|
|
63
|
+
disrupt live sessions on stage too). Use --yes to skip.
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
optima-gateway-admin GET /admin/llm-rates --env cn-stage
|
|
67
|
+
optima-gateway-admin GET /admin/coo/instances --env cn-prod
|
|
68
|
+
optima-gateway-admin POST /admin/coo/users/<userId>/kill --env cn-stage
|
|
69
|
+
optima-gateway-admin PUT /admin/config/SOME_KEY '{"value":"x"}' --env cn-stage`);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseArgs(args: string[]): Parsed {
|
|
74
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') usage();
|
|
75
|
+
|
|
76
|
+
let env = 'cn-stage';
|
|
77
|
+
let yes = false;
|
|
78
|
+
const positional: string[] = [];
|
|
79
|
+
for (let i = 0; i < args.length; i++) {
|
|
80
|
+
const a = args[i];
|
|
81
|
+
if (a === '--env' || a === '-e') {
|
|
82
|
+
env = args[++i] ?? '';
|
|
83
|
+
} else if (a.startsWith('--env=')) {
|
|
84
|
+
env = a.slice('--env='.length);
|
|
85
|
+
} else if (a === '--yes' || a === '-y') {
|
|
86
|
+
yes = true;
|
|
87
|
+
} else if (a.startsWith('--')) {
|
|
88
|
+
console.error(`❌ Unknown flag: ${a}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
} else {
|
|
91
|
+
positional.push(a);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!GATEWAY_URLS[env]) {
|
|
96
|
+
console.error(`❌ --env must be "cn-stage" or "cn-prod" (got: ${env}). AWS stage 未接(prod gateway 已关停),需要时见 #70。`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const method = (positional[0] ?? '').toUpperCase();
|
|
101
|
+
if (!['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
102
|
+
console.error(`❌ METHOD must be GET/HEAD/POST/PUT/PATCH/DELETE (got: ${positional[0] ?? '(missing)'})`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const path = positional[1] ?? '';
|
|
107
|
+
// 硬约束 /admin/ 前缀:本命令的 token 只该用于 admin 面;防拼错路径把
|
|
108
|
+
// gateway:admin token 打到任意端点(最小暴露面)。
|
|
109
|
+
if (!path.startsWith('/admin/')) {
|
|
110
|
+
console.error(`❌ path must start with /admin/ (got: ${path || '(missing)'})`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let body: string | null = null;
|
|
115
|
+
if (positional[2] !== undefined) {
|
|
116
|
+
if (!WRITE_METHODS.has(method)) {
|
|
117
|
+
console.error(`❌ ${method} does not take a body`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
JSON.parse(positional[2]);
|
|
122
|
+
} catch {
|
|
123
|
+
console.error(`❌ jsonBody is not valid JSON: ${positional[2].slice(0, 120)}`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
body = positional[2];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { method, path, body, env, yes };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function confirmWrite(p: Parsed, url: string): Promise<void> {
|
|
133
|
+
if (!WRITE_METHODS.has(p.method) || p.yes) return;
|
|
134
|
+
const prodMark = p.env === 'cn-prod' ? ' 🔴 PRODUCTION' : '';
|
|
135
|
+
console.log(`\n⚠️ About to ${p.method} on ${p.env.toUpperCase()}${prodMark}:\n ${p.method} ${url}${p.body ? `\n body: ${p.body}` : ''}\n`);
|
|
136
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
137
|
+
const answer = await new Promise<string>((resolve) => {
|
|
138
|
+
rl.question('Type "yes" to confirm: ', (a) => { rl.close(); resolve(a.trim()); });
|
|
139
|
+
});
|
|
140
|
+
if (answer !== 'yes') {
|
|
141
|
+
console.error('❌ Aborted by user.');
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function main(): Promise<void> {
|
|
147
|
+
const p = parseArgs(process.argv.slice(2));
|
|
148
|
+
const url = `${GATEWAY_URLS[p.env]}${p.path}`;
|
|
149
|
+
|
|
150
|
+
await confirmWrite(p, url);
|
|
151
|
+
|
|
152
|
+
const token = getServiceToken(p.env, GATEWAY_ADMIN_SCOPE);
|
|
153
|
+
|
|
154
|
+
const res = await fetch(url, {
|
|
155
|
+
method: p.method,
|
|
156
|
+
headers: {
|
|
157
|
+
Authorization: `Bearer ${token}`,
|
|
158
|
+
...(p.body ? { 'Content-Type': 'application/json' } : {}),
|
|
159
|
+
},
|
|
160
|
+
...(p.body ? { body: p.body } : {}),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const text = await res.text();
|
|
164
|
+
const statusLine = `${res.status} ${res.statusText}`;
|
|
165
|
+
if (!res.ok) {
|
|
166
|
+
console.error(`❌ [${statusLine}] ${p.method} ${p.path} (${p.env})`);
|
|
167
|
+
if (res.status === 403) {
|
|
168
|
+
console.error(' 403 提示:token scope 可能为空——确认该环境 dev-skills client 的 allowed_scopes 已含 gateway:admin(#70)。');
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
console.log(`✓ [${statusLine}] ${p.method} ${p.path} (${p.env})`);
|
|
172
|
+
}
|
|
173
|
+
if (text) {
|
|
174
|
+
try {
|
|
175
|
+
console.log(JSON.stringify(JSON.parse(text), null, 2));
|
|
176
|
+
} catch {
|
|
177
|
+
console.log(text.slice(0, 4000));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
process.exit(res.ok ? 0 : 1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
main().catch((err) => {
|
|
184
|
+
console.error(`\n❌ Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
});
|
|
@@ -114,9 +114,14 @@ function getSkillsUrl(env) {
|
|
|
114
114
|
skillsUrlCache[env] = url;
|
|
115
115
|
return url;
|
|
116
116
|
}
|
|
117
|
-
function getServiceToken(env) {
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
function getServiceToken(env, scope) {
|
|
118
|
+
// Cache key includes scope: the same env can mint tokens with different
|
|
119
|
+
// scopes (default internal:users:write vs gateway:admin for gateway-admin,
|
|
120
|
+
// #70) and user-auth issues request∩allowed_scopes — mixing them up would
|
|
121
|
+
// hand a wrongly-scoped token to the caller.
|
|
122
|
+
const cacheKey = scope ? `${env}:${scope}` : env;
|
|
123
|
+
if (tokenCache[cacheKey])
|
|
124
|
+
return tokenCache[cacheKey];
|
|
120
125
|
// cn-prod 与 cn-stage 都用 client_credentials M2M token,scope=internal:users:write。
|
|
121
126
|
// 凭证来源不同:
|
|
122
127
|
// · cn-prod —— 镜像在 AWS Infisical prod 环境(CN_PROD_ 前缀键),复用既有 AWS 访问。
|
|
@@ -148,7 +153,8 @@ function getServiceToken(env) {
|
|
|
148
153
|
const authUrl = USER_AUTH_URLS[env];
|
|
149
154
|
if (!authUrl)
|
|
150
155
|
throw new Error(`Unknown env: ${env}`);
|
|
151
|
-
const
|
|
156
|
+
const effectiveScope = scope ?? (isCn ? CN_PROD_TOKEN_SCOPE : undefined);
|
|
157
|
+
const scopeParam = effectiveScope ? `&scope=${encodeURIComponent(effectiveScope)}` : '';
|
|
152
158
|
const body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}${scopeParam}`;
|
|
153
159
|
const response = (0, child_process_1.execSync)(`curl -s -X POST '${authUrl}/api/v1/oauth/token' -H 'Content-Type: application/x-www-form-urlencoded' -d '${body}'`, { encoding: 'utf-8' });
|
|
154
160
|
let parsed;
|
|
@@ -161,7 +167,7 @@ function getServiceToken(env) {
|
|
|
161
167
|
if (!parsed.access_token) {
|
|
162
168
|
throw new Error(`user-auth token mint failed (${env}): ${response.slice(0, 200)}`);
|
|
163
169
|
}
|
|
164
|
-
tokenCache[
|
|
170
|
+
tokenCache[cacheKey] = parsed.access_token;
|
|
165
171
|
return parsed.access_token;
|
|
166
172
|
}
|
|
167
173
|
function formatServiceError(status, statusText, body) {
|
|
@@ -137,7 +137,10 @@ async function main() {
|
|
|
137
137
|
devops('TriggerRepositoryMirrorSync', { repositoryId: String(repoId), account: 'xbfool', token: ghTok });
|
|
138
138
|
let synced = false;
|
|
139
139
|
for (let i = 0; i < 30; i++) {
|
|
140
|
-
|
|
140
|
+
// vtag 发版时 ref 是 tag,不是 branch —— GetBranchInfo 查不到 tag 会恒超时,改用 tag 接口。
|
|
141
|
+
const b = vtag
|
|
142
|
+
? devops('GetRepositoryTag', { repositoryId: String(repoId), tagName: ref })
|
|
143
|
+
: devops('GetBranchInfo', { repositoryId: String(repoId), branchName: ref });
|
|
141
144
|
if (b?.result?.commit?.id === ghSha) {
|
|
142
145
|
synced = true;
|
|
143
146
|
break;
|
|
@@ -43,6 +43,8 @@ exports.getGitHubVariable = getGitHubVariable;
|
|
|
43
43
|
exports.getInfisicalConfig = getInfisicalConfig;
|
|
44
44
|
exports.getInfisicalToken = getInfisicalToken;
|
|
45
45
|
exports.getInfisicalSecrets = getInfisicalSecrets;
|
|
46
|
+
exports.loadCredsFileIntoEnv = loadCredsFileIntoEnv;
|
|
47
|
+
exports.readBuildboxPwFile = readBuildboxPwFile;
|
|
46
48
|
exports.getCnInfisicalToken = getCnInfisicalToken;
|
|
47
49
|
exports.getCnSecrets = getCnSecrets;
|
|
48
50
|
exports.parseDatabaseUrl = parseDatabaseUrl;
|
|
@@ -137,15 +139,87 @@ function curlJson(args) {
|
|
|
137
139
|
throw new Error(`cn Infisical: non-JSON response: ${String(out).slice(0, 200)}`);
|
|
138
140
|
}
|
|
139
141
|
}
|
|
142
|
+
/** 明文密码文件权限比 600 宽(组/其他用户可读)时往 stderr 警告一行。Windows 无此语义,跳过。 */
|
|
143
|
+
function warnIfLoosePerms(file) {
|
|
144
|
+
if (process.platform === 'win32')
|
|
145
|
+
return;
|
|
146
|
+
try {
|
|
147
|
+
const mode = fs.statSync(file).mode & 0o777;
|
|
148
|
+
if (mode & 0o077)
|
|
149
|
+
console.error(`⚠️ ${file} 权限 ${mode.toString(8).padStart(3, '0')}(同机其他用户可读)——建议 chmod 600 ${file}`);
|
|
150
|
+
}
|
|
151
|
+
catch { /* stat 失败不阻塞主流程 */ }
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* 若 creds 文件存在,把其中 `export KEY=val` / `KEY=val` 行注入 process.env(已存在的键不覆盖)。
|
|
155
|
+
* 免去每次手动 `source ~/.infisical_cn_creds`;文件不存在则静默跳过,回退到调用方的 env 检查。
|
|
156
|
+
* ⚠️ 不是 shell:只认最朴素的每行一条 `KEY=值`。值以引号开头时取到配对引号为止(其后内容
|
|
157
|
+
* 忽略,行尾注释因此无害);引号未闭合的行警告并跳过(真 shell 会语法错,静默注入 `"abc`
|
|
158
|
+
* 这类错值只会换来下游 401、更难查);无引号值取到行尾原样保留(别写行尾注释,`#` 会进值)。
|
|
159
|
+
* 导出仅供测试。
|
|
160
|
+
*/
|
|
161
|
+
const loadedCredsFiles = new Set(); // 同一文件只加载/警告一次(getCnInfisicalToken 与 setupCnSSHTunnel 都会调)
|
|
162
|
+
function loadCredsFileIntoEnv(file) {
|
|
163
|
+
if (loadedCredsFiles.has(file))
|
|
164
|
+
return;
|
|
165
|
+
loadedCredsFiles.add(file);
|
|
166
|
+
let content;
|
|
167
|
+
try {
|
|
168
|
+
content = fs.readFileSync(file, 'utf-8');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return;
|
|
172
|
+
} // 文件不存在/不可读 = 无操作,保持原有「读 env」行为
|
|
173
|
+
warnIfLoosePerms(file);
|
|
174
|
+
const lines = content.split(/\r?\n/);
|
|
175
|
+
for (let i = 0; i < lines.length; i++) {
|
|
176
|
+
const m = lines[i].match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
177
|
+
if (!m)
|
|
178
|
+
continue;
|
|
179
|
+
const key = m[1];
|
|
180
|
+
if (process.env[key] !== undefined)
|
|
181
|
+
continue; // 已有 env 优先,不覆盖
|
|
182
|
+
let val = m[2].trim();
|
|
183
|
+
const q = val[0];
|
|
184
|
+
if (q === '"' || q === "'") {
|
|
185
|
+
const close = val.indexOf(q, 1);
|
|
186
|
+
if (close === -1) {
|
|
187
|
+
console.error(`⚠️ ${file} 第 ${i + 1} 行(${key}=)引号未闭合,已跳过该行`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
val = val.slice(1, close); // 配对引号截取,忽略其后(如行尾注释)
|
|
191
|
+
}
|
|
192
|
+
process.env[key] = val;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* 读 buildbox 密码文件(纯密码一行;默认 ~/.buildbox_pw,OPTIMA_CN_BUILDBOX_PW_FILE 可覆盖)。
|
|
197
|
+
* 文件不存在/为空 → undefined,回退到调用方报错。导出仅供测试。
|
|
198
|
+
*/
|
|
199
|
+
function readBuildboxPwFile() {
|
|
200
|
+
const file = process.env.OPTIMA_CN_BUILDBOX_PW_FILE || `${os.homedir()}/.buildbox_pw`;
|
|
201
|
+
let pw;
|
|
202
|
+
try {
|
|
203
|
+
pw = fs.readFileSync(file, 'utf-8').trim();
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
warnIfLoosePerms(file); // 读到文件就查权限,空文件也警告
|
|
209
|
+
if (!pw)
|
|
210
|
+
return undefined;
|
|
211
|
+
return pw;
|
|
212
|
+
}
|
|
140
213
|
/**
|
|
141
214
|
* 认证 cn Infisical(admin email/password → org-scoped token)。
|
|
142
215
|
* ⚠️ 字段名坑:login 返回 `accessToken`(不是 token),select-organization 才返回 `token`。
|
|
143
216
|
*/
|
|
144
217
|
function getCnInfisicalToken() {
|
|
218
|
+
loadCredsFileIntoEnv(process.env.INFISICAL_CN_CREDS_FILE || `${os.homedir()}/.infisical_cn_creds`);
|
|
145
219
|
const email = process.env.INFISICAL_CN_EMAIL;
|
|
146
220
|
const password = process.env.INFISICAL_CN_PASSWORD;
|
|
147
221
|
if (!email || !password) {
|
|
148
|
-
throw new Error('cn Infisical 需要 INFISICAL_CN_EMAIL + INFISICAL_CN_PASSWORD
|
|
222
|
+
throw new Error('cn Infisical 需要 INFISICAL_CN_EMAIL + INFISICAL_CN_PASSWORD。① 从 1Password "Infisical cn-prod admin (secrets-cn.optima.chat)" 取值 export(不落盘);或 ② 本工具自动读取本地文件 ~/.infisical_cn_creds(每行 `export KEY=val`,INFISICAL_CN_CREDS_FILE 可覆盖)。见 optima-dev-skills#21。');
|
|
149
223
|
}
|
|
150
224
|
const login = curlJson([
|
|
151
225
|
'-X', 'POST', `${CN_INFISICAL_URL}/api/v3/auth/login`,
|
|
@@ -357,9 +431,11 @@ function setupSSMTunnel(dbHost, localPort) {
|
|
|
357
431
|
* 密码经 SSHPASS 环境变量传给 `sshpass -e`:不进命令行(ps 不可见)、不经 shell 引号展开。
|
|
358
432
|
*/
|
|
359
433
|
function setupCnSSHTunnel(dbHost, localPort) {
|
|
360
|
-
|
|
434
|
+
// creds 文件里也可能放 OPTIMA_CN_BUILDBOX_PASSWORD——自己加载一次(幂等),不依赖 getCnInfisicalToken 恰好先跑
|
|
435
|
+
loadCredsFileIntoEnv(process.env.INFISICAL_CN_CREDS_FILE || `${os.homedir()}/.infisical_cn_creds`);
|
|
436
|
+
const pw = process.env.OPTIMA_CN_BUILDBOX_PASSWORD || readBuildboxPwFile();
|
|
361
437
|
if (!pw)
|
|
362
|
-
throw new Error('cn DB 隧道需要 OPTIMA_CN_BUILDBOX_PASSWORD
|
|
438
|
+
throw new Error('cn DB 隧道需要 OPTIMA_CN_BUILDBOX_PASSWORD。① 从 1Password "Aliyun cn-prod buildbox ECS (root)" 取值 export;或 ② 本工具自动读取本地文件 ~/.buildbox_pw(纯密码一行,OPTIMA_CN_BUILDBOX_PW_FILE 可覆盖路径)。见 optima-dev-skills#21。');
|
|
363
439
|
try {
|
|
364
440
|
(0, child_process_1.execSync)('command -v sshpass', { stdio: 'ignore' });
|
|
365
441
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// optima-gateway-admin — 通用透传型 gateway-core /admin/* 操作 CLI(#70)。
|
|
4
|
+
//
|
|
5
|
+
// 定位:dev-skills 现有 admin 技能(account/grant-credits/entitlement)打的都是
|
|
6
|
+
// user-auth 与 billing;gateway 自身的 /admin/*(COO kill、warm-pool drain、
|
|
7
|
+
// credits adjust、provider key CRUD、config CRUD、llm-rates、models 等)此前只能
|
|
8
|
+
// 手写 curl + 手拼 token(#1681 止血即被迫手写 DB UPDATE + aliyun CLI)。本命令
|
|
9
|
+
// 把「铸 gateway:admin token → 调端点 → 展示结果」收敛成一条命令。
|
|
10
|
+
//
|
|
11
|
+
// 凭证(#70 方向 1):复用 dev-skills 各环境既有 OAuth client(client_credentials),
|
|
12
|
+
// 其 allowed_scopes 已加 gateway:admin(cn-stage/cn-prod 2026-07-23 提权);
|
|
13
|
+
// token 铸造显式带 scope=gateway:admin(user-auth 发 request∩allowed_scopes,
|
|
14
|
+
// 不显式带则 scope 为空 → gateway 403,见 #70 技术现状)。
|
|
15
|
+
//
|
|
16
|
+
// 安全门:GET/HEAD 直通;写方法(POST/PUT/PATCH/DELETE)一律回显完整
|
|
17
|
+
// method+URL+body 后要求交互确认(所有环境,非仅 prod——admin 面写操作在
|
|
18
|
+
// stage 也可能打断在跑会话),--yes 跳过(脚本化用)。
|
|
19
|
+
//
|
|
20
|
+
// v1 支持 cn-stage / cn-prod(AWS prod gateway 已关停 2026-06-26;AWS stage
|
|
21
|
+
// 需要时再接 domain-urls,见 #70 讨论)。
|
|
22
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
25
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
26
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
27
|
+
}
|
|
28
|
+
Object.defineProperty(o, k2, desc);
|
|
29
|
+
}) : (function(o, m, k, k2) {
|
|
30
|
+
if (k2 === undefined) k2 = k;
|
|
31
|
+
o[k2] = m[k];
|
|
32
|
+
}));
|
|
33
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
34
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
35
|
+
}) : function(o, v) {
|
|
36
|
+
o["default"] = v;
|
|
37
|
+
});
|
|
38
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
39
|
+
var ownKeys = function(o) {
|
|
40
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
41
|
+
var ar = [];
|
|
42
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
43
|
+
return ar;
|
|
44
|
+
};
|
|
45
|
+
return ownKeys(o);
|
|
46
|
+
};
|
|
47
|
+
return function (mod) {
|
|
48
|
+
if (mod && mod.__esModule) return mod;
|
|
49
|
+
var result = {};
|
|
50
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
51
|
+
__setModuleDefault(result, mod);
|
|
52
|
+
return result;
|
|
53
|
+
};
|
|
54
|
+
})();
|
|
55
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
56
|
+
const readline = __importStar(require("readline"));
|
|
57
|
+
const billing_http_1 = require("./billing-http");
|
|
58
|
+
const GATEWAY_ADMIN_SCOPE = 'gateway:admin';
|
|
59
|
+
// 与 verify-health.ts 的 gateway-core 域名同源(cn 域名稳定、硬编码,与
|
|
60
|
+
// billing-http 的 cn URL 处理同一 rationale)。
|
|
61
|
+
const GATEWAY_URLS = {
|
|
62
|
+
'cn-prod': 'https://gw.yzsgo.com',
|
|
63
|
+
'cn-stage': 'https://gw.stage.optima.chat',
|
|
64
|
+
};
|
|
65
|
+
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
66
|
+
function usage() {
|
|
67
|
+
console.log(`Usage: optima-gateway-admin <METHOD> <path> [jsonBody] [options]
|
|
68
|
+
|
|
69
|
+
Call a gateway-core /admin/* endpoint with a gateway:admin service token (#70).
|
|
70
|
+
|
|
71
|
+
Arguments:
|
|
72
|
+
METHOD GET | POST | PUT | PATCH | DELETE
|
|
73
|
+
path Endpoint path, must start with /admin/
|
|
74
|
+
jsonBody JSON request body (write methods only)
|
|
75
|
+
|
|
76
|
+
Options:
|
|
77
|
+
--env <env> cn-stage (default) | cn-prod
|
|
78
|
+
--yes Skip the write-confirmation prompt (scripting)
|
|
79
|
+
-h, --help Show this help
|
|
80
|
+
|
|
81
|
+
Safety:
|
|
82
|
+
GET/HEAD run directly. POST/PUT/PATCH/DELETE echo the full request and
|
|
83
|
+
require typing "yes" — on EVERY env, not just prod (admin writes can
|
|
84
|
+
disrupt live sessions on stage too). Use --yes to skip.
|
|
85
|
+
|
|
86
|
+
Examples:
|
|
87
|
+
optima-gateway-admin GET /admin/llm-rates --env cn-stage
|
|
88
|
+
optima-gateway-admin GET /admin/coo/instances --env cn-prod
|
|
89
|
+
optima-gateway-admin POST /admin/coo/users/<userId>/kill --env cn-stage
|
|
90
|
+
optima-gateway-admin PUT /admin/config/SOME_KEY '{"value":"x"}' --env cn-stage`);
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
function parseArgs(args) {
|
|
94
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h')
|
|
95
|
+
usage();
|
|
96
|
+
let env = 'cn-stage';
|
|
97
|
+
let yes = false;
|
|
98
|
+
const positional = [];
|
|
99
|
+
for (let i = 0; i < args.length; i++) {
|
|
100
|
+
const a = args[i];
|
|
101
|
+
if (a === '--env' || a === '-e') {
|
|
102
|
+
env = args[++i] ?? '';
|
|
103
|
+
}
|
|
104
|
+
else if (a.startsWith('--env=')) {
|
|
105
|
+
env = a.slice('--env='.length);
|
|
106
|
+
}
|
|
107
|
+
else if (a === '--yes' || a === '-y') {
|
|
108
|
+
yes = true;
|
|
109
|
+
}
|
|
110
|
+
else if (a.startsWith('--')) {
|
|
111
|
+
console.error(`❌ Unknown flag: ${a}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
positional.push(a);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!GATEWAY_URLS[env]) {
|
|
119
|
+
console.error(`❌ --env must be "cn-stage" or "cn-prod" (got: ${env}). AWS stage 未接(prod gateway 已关停),需要时见 #70。`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const method = (positional[0] ?? '').toUpperCase();
|
|
123
|
+
if (!['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
124
|
+
console.error(`❌ METHOD must be GET/HEAD/POST/PUT/PATCH/DELETE (got: ${positional[0] ?? '(missing)'})`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
const path = positional[1] ?? '';
|
|
128
|
+
// 硬约束 /admin/ 前缀:本命令的 token 只该用于 admin 面;防拼错路径把
|
|
129
|
+
// gateway:admin token 打到任意端点(最小暴露面)。
|
|
130
|
+
if (!path.startsWith('/admin/')) {
|
|
131
|
+
console.error(`❌ path must start with /admin/ (got: ${path || '(missing)'})`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
let body = null;
|
|
135
|
+
if (positional[2] !== undefined) {
|
|
136
|
+
if (!WRITE_METHODS.has(method)) {
|
|
137
|
+
console.error(`❌ ${method} does not take a body`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
JSON.parse(positional[2]);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
console.error(`❌ jsonBody is not valid JSON: ${positional[2].slice(0, 120)}`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
body = positional[2];
|
|
148
|
+
}
|
|
149
|
+
return { method, path, body, env, yes };
|
|
150
|
+
}
|
|
151
|
+
async function confirmWrite(p, url) {
|
|
152
|
+
if (!WRITE_METHODS.has(p.method) || p.yes)
|
|
153
|
+
return;
|
|
154
|
+
const prodMark = p.env === 'cn-prod' ? ' 🔴 PRODUCTION' : '';
|
|
155
|
+
console.log(`\n⚠️ About to ${p.method} on ${p.env.toUpperCase()}${prodMark}:\n ${p.method} ${url}${p.body ? `\n body: ${p.body}` : ''}\n`);
|
|
156
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
157
|
+
const answer = await new Promise((resolve) => {
|
|
158
|
+
rl.question('Type "yes" to confirm: ', (a) => { rl.close(); resolve(a.trim()); });
|
|
159
|
+
});
|
|
160
|
+
if (answer !== 'yes') {
|
|
161
|
+
console.error('❌ Aborted by user.');
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function main() {
|
|
166
|
+
const p = parseArgs(process.argv.slice(2));
|
|
167
|
+
const url = `${GATEWAY_URLS[p.env]}${p.path}`;
|
|
168
|
+
await confirmWrite(p, url);
|
|
169
|
+
const token = (0, billing_http_1.getServiceToken)(p.env, GATEWAY_ADMIN_SCOPE);
|
|
170
|
+
const res = await fetch(url, {
|
|
171
|
+
method: p.method,
|
|
172
|
+
headers: {
|
|
173
|
+
Authorization: `Bearer ${token}`,
|
|
174
|
+
...(p.body ? { 'Content-Type': 'application/json' } : {}),
|
|
175
|
+
},
|
|
176
|
+
...(p.body ? { body: p.body } : {}),
|
|
177
|
+
});
|
|
178
|
+
const text = await res.text();
|
|
179
|
+
const statusLine = `${res.status} ${res.statusText}`;
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
console.error(`❌ [${statusLine}] ${p.method} ${p.path} (${p.env})`);
|
|
182
|
+
if (res.status === 403) {
|
|
183
|
+
console.error(' 403 提示:token scope 可能为空——确认该环境 dev-skills client 的 allowed_scopes 已含 gateway:admin(#70)。');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
console.log(`✓ [${statusLine}] ${p.method} ${p.path} (${p.env})`);
|
|
188
|
+
}
|
|
189
|
+
if (text) {
|
|
190
|
+
try {
|
|
191
|
+
console.log(JSON.stringify(JSON.parse(text), null, 2));
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
console.log(text.slice(0, 4000));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
process.exit(res.ok ? 0 : 1);
|
|
198
|
+
}
|
|
199
|
+
main().catch((err) => {
|
|
200
|
+
console.error(`\n❌ Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optima-chat/dev-skills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Claude Code Skills for Optima development team - cross-environment collaboration tools",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"optima-query-db": "dist/bin/helpers/query-db.js",
|
|
19
19
|
"optima-show-env": "dist/bin/helpers/show-env.js",
|
|
20
20
|
"optima-verify-health": "dist/bin/helpers/verify-health.js",
|
|
21
|
-
"optima-cn-deploy": "dist/bin/helpers/cn-deploy.js"
|
|
21
|
+
"optima-cn-deploy": "dist/bin/helpers/cn-deploy.js",
|
|
22
|
+
"optima-gateway-admin": "dist/bin/helpers/gateway-admin.js"
|
|
22
23
|
},
|
|
23
24
|
"scripts": {
|
|
24
25
|
"postinstall": "node scripts/install.js",
|