@optima-chat/dev-skills 0.7.43 → 0.8.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.
Files changed (38) hide show
  1. package/.claude/skills/grant-balance/SKILL.md +4 -2
  2. package/.claude/skills/grant-subscription/SKILL.md +5 -1
  3. package/.codex/skills/grant-balance/SKILL.md +2 -1
  4. package/.codex/skills/grant-subscription/SKILL.md +6 -2
  5. package/bin/cli.js +1 -1
  6. package/bin/helpers/billing-http.ts +55 -16
  7. package/bin/helpers/confirm-prompt.ts +8 -4
  8. package/bin/helpers/db-utils.ts +34 -12
  9. package/bin/helpers/discount/create.ts +3 -3
  10. package/bin/helpers/discount/disable.ts +3 -3
  11. package/bin/helpers/discount/generate.ts +3 -3
  12. package/bin/helpers/discount/list.ts +3 -3
  13. package/bin/helpers/entitlement/grant.ts +4 -7
  14. package/bin/helpers/entitlement/list.ts +4 -7
  15. package/bin/helpers/entitlement/revoke.ts +4 -7
  16. package/bin/helpers/generate-test-token.ts +28 -10
  17. package/bin/helpers/grant-balance.ts +6 -5
  18. package/bin/helpers/grant-subscription.ts +10 -8
  19. package/bin/helpers/query-db.ts +9 -6
  20. package/bin/helpers/show-env.ts +34 -15
  21. package/bin/helpers/verify-health.ts +10 -9
  22. package/dist/bin/helpers/billing-http.js +55 -14
  23. package/dist/bin/helpers/confirm-prompt.js +7 -4
  24. package/dist/bin/helpers/db-utils.js +34 -12
  25. package/dist/bin/helpers/discount/create.js +2 -2
  26. package/dist/bin/helpers/discount/disable.js +2 -2
  27. package/dist/bin/helpers/discount/generate.js +2 -2
  28. package/dist/bin/helpers/discount/list.js +2 -2
  29. package/dist/bin/helpers/entitlement/grant.js +3 -6
  30. package/dist/bin/helpers/entitlement/list.js +3 -6
  31. package/dist/bin/helpers/entitlement/revoke.js +3 -6
  32. package/dist/bin/helpers/generate-test-token.js +29 -9
  33. package/dist/bin/helpers/grant-balance.js +6 -5
  34. package/dist/bin/helpers/grant-subscription.js +10 -8
  35. package/dist/bin/helpers/query-db.js +9 -6
  36. package/dist/bin/helpers/show-env.js +31 -12
  37. package/dist/bin/helpers/verify-health.js +8 -7
  38. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "grant-balance"
3
- description: "当用户请求赠送余额、充值 USD 余额、grant balance、加余额、奖励、补偿、推荐奖励、运营发放时,使用此技能。支持 Stage、Prod、cn-prod 三个环境。"
3
+ description: "当用户请求赠送余额、充值 USD 余额、grant balance、加余额、奖励、补偿、推荐奖励、运营发放时,使用此技能。支持 Stage、Prod、cn-prod、cn-stage 四个环境。"
4
4
  allowed-tools: ["Bash"]
5
5
  ---
6
6
 
@@ -50,9 +50,11 @@ optima-grant-balance user@example.com --amount 20 --description "服务中断补
50
50
  | `<email>` | 用户邮箱(必填) | - |
51
51
  | `--amount <usd>` | USD 金额(必填,> 0) | - |
52
52
  | `--description <text>` | 描述/原因(仅 console 输出) | - |
53
- | `--env <env>` | 环境:stage, prod, cn-prod | stage |
53
+ | `--env <env>` | 环境:stage, prod, cn-prod, cn-stage | stage |
54
54
 
55
55
  > **cn-prod(国内环境)**:全程 HTTPS(auth.yzsgo.com / billing-api.yzsgo.com),email 查找走 user-auth internal lookup API(无 SSH 隧道)。金额输入仍是 USD($1 = 700 积分,与 CN ¥1 = 100 积分同一账本单位)。例:`optima-grant-balance user@example.com --amount 1 --env cn-prod`
56
+ >
57
+ > **cn-stage(阿里云预发)**:同 cn-prod 走 HTTPS(auth.stage.optima.chat / billing-api.stage.optima.chat)。M2M 凭证读 cn Infisical **staging** /shared-secrets/oauth-clients,运行时需 `INFISICAL_CN_EMAIL`/`INFISICAL_CN_PASSWORD`(admin user)。例:`optima-grant-balance user@example.com --amount 1 --env cn-stage`
56
58
 
57
59
  ## 与 grant-subscription 的区别
58
60
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "grant-subscription"
3
- description: "当用户请求开通会员、赠送订阅、grant subscription、开通 Pro/Starter/Enterprise、升级计划、给用户开会员时,使用此技能。支持 Stage、Prod、cn-prod 三个环境。"
3
+ description: "当用户请求开通会员、赠送订阅、grant subscription、开通 Pro/Starter/Enterprise、升级计划、给用户开会员时,使用此技能。支持 Stage、Prod、cn-prod、cn-stage 四个环境。"
4
4
  allowed-tools: ["Bash"]
5
5
  ---
6
6
 
@@ -50,6 +50,10 @@ optima-grant-subscription user@example.com --plan enterprise --env prod
50
50
  # cn-prod(国内环境):plan 用 -cn 档,默认 pro-cn
51
51
  optima-grant-subscription user@example.com --env cn-prod
52
52
  optima-grant-subscription user@example.com --plan starter-cn --env cn-prod
53
+
54
+ # cn-stage(阿里云预发):同 cn-prod 用 -cn 档;需 INFISICAL_CN_EMAIL/PASSWORD。
55
+ # ⚠️ cn-stage billing 的 plan 目录可能未 seed(报 403 Plan not found)——属 cn-stage 基建缺口,非本工具问题。
56
+ optima-grant-subscription user@example.com --plan pro-cn --env cn-stage
53
57
  ```
54
58
 
55
59
  ### 参数说明
@@ -49,9 +49,10 @@ optima-grant-balance user@example.com --amount 20 --description "服务中断补
49
49
  | `<email>` | 用户邮箱(必填) | - |
50
50
  | `--amount <usd>` | USD 金额(必填,> 0) | - |
51
51
  | `--description <text>` | 描述/原因(仅 console 输出) | - |
52
- | `--env <env>` | 环境:stage, prod, cn-prod | stage |
52
+ | `--env <env>` | 环境:stage, prod, cn-prod, cn-stage | stage |
53
53
 
54
54
  > **cn-prod**:走 HTTPS(auth.yzsgo.com / billing-api.yzsgo.com),email 查找经 user-auth internal lookup API(无 SSH 隧道)。金额输入仍是 USD($1 = 700 积分 = ¥7 档积分口径一致)。
55
+ > **cn-stage(阿里云预发)**:同 cn-prod 走 HTTPS(auth.stage.optima.chat / billing-api.stage.optima.chat);M2M 凭证读 cn Infisical staging,需 `INFISICAL_CN_EMAIL`/`INFISICAL_CN_PASSWORD`。
55
56
 
56
57
  ## 与 grant-subscription 的区别
57
58
 
@@ -25,12 +25,16 @@ optima-grant-subscription user@example.com --plan enterprise --env prod
25
25
  # cn-prod(国内环境):plan 用 -cn 档,默认 pro-cn
26
26
  optima-grant-subscription user@example.com --env cn-prod
27
27
  optima-grant-subscription user@example.com --plan starter-cn --env cn-prod
28
+
29
+ # cn-stage(阿里云预发):同 cn-prod 用 -cn 档;需 INFISICAL_CN_EMAIL/PASSWORD。
30
+ # ⚠️ cn-stage billing plan 目录可能未 seed(403 Plan not found)——cn-stage 基建缺口,非本工具问题。
31
+ optima-grant-subscription user@example.com --plan pro-cn --env cn-stage
28
32
  ```
29
33
 
30
34
  ## Guidance
31
35
 
32
36
  - Default to `stage`.
33
- - Confirm the user email before running on `prod` / `cn-prod`.
34
- - `cn-prod` plans are the CNY-priced `-cn` ids (`starter-cn`, `pro-cn`, `enterprise-cn`, plus `trial`); bare USD ids are rejected client-side to avoid granting a USD-priced plan to a CN user.
37
+ - Confirm the user email before running on `prod` / `cn-prod` / `cn-stage`.
38
+ - `cn-prod` / `cn-stage` plans are the CNY-priced `-cn` ids (`starter-cn`, `pro-cn`, `enterprise-cn`, plus `trial`); bare USD ids are rejected client-side to avoid granting a USD-priced plan to a CN user.
35
39
  - This operation replaces existing subscription state and resets the wallet granted balance according to the selected plan.
36
40
  - Use `optima-query-db` afterward if the user asks for verification.
package/bin/cli.js CHANGED
@@ -44,7 +44,7 @@ switch (command) {
44
44
  log(' session-gateway optima-scout billing browser-backend optima-generation', 'cyan');
45
45
 
46
46
  log('\nEnvironments:', 'yellow');
47
- log(' stage (default) prod', 'cyan');
47
+ log(' stage (default) prod cn-prod (阿里云生产) cn-stage (阿里云预发)', 'cyan');
48
48
 
49
49
  log('\nExamples:', 'yellow');
50
50
  log(' /logs commerce-backend 100 prod', 'cyan');
@@ -1,19 +1,22 @@
1
1
  import { execSync } from 'child_process';
2
2
  import { fetchInfisicalSecret } from './infisical-secrets';
3
- import { getInfisicalConfig, getInfisicalToken } from './db-utils';
3
+ import { getInfisicalConfig, getInfisicalToken, getCnInfisicalToken, getCnSecrets, resolveUserId } from './db-utils';
4
4
 
5
5
  const USER_AUTH_URLS: Record<string, string> = {
6
6
  stage: 'https://auth.stage.optima.onl',
7
7
  prod: 'https://auth.optima.onl',
8
8
  // #201: yzsgo.com 全量迁移 (2026-06-12), 旧 auth-cn.optima.chat 路由已下线
9
9
  'cn-prod': 'https://auth.yzsgo.com',
10
+ // cn-stage(阿里云预发,独立于 AWS stage `.optima.onl`)
11
+ 'cn-stage': 'https://auth.stage.optima.chat',
10
12
  };
11
13
 
12
- // cn-prod URLs are hardcoded: cn Infisical (secrets-cn.optima.chat) is a
14
+ // cn URLs are hardcoded: cn Infisical (secrets-cn.optima.chat) is a
13
15
  // separate instance dev-skills has no machine identity for, and these domains
14
16
  // are stable. AWS envs keep reading /shared-secrets/domain-urls.
15
17
  // #201: yzsgo.com 全量迁移 (2026-06-12), 旧 billing-cn.optima.chat 路由已下线
16
18
  const CN_PROD_BILLING_URL = 'https://billing-api.yzsgo.com';
19
+ const CN_STAGE_BILLING_URL = 'https://billing-api.stage.optima.chat';
17
20
 
18
21
  /**
19
22
  * Validate the --env flag value at command entry, before any I/O.
@@ -39,9 +42,9 @@ export function validateEnv(env: string): 'stage' | 'prod' {
39
42
  * exist for cn-prod (Aliyun VPC-internal RDS) — keep them on validateEnv so a
40
43
  * cn-prod typo fails fast instead of dying inside the tunnel setup.
41
44
  */
42
- export function validateEnvCnProd(env: string): 'stage' | 'prod' | 'cn-prod' {
43
- if (env !== 'stage' && env !== 'prod' && env !== 'cn-prod') {
44
- throw new Error(`--env must be "stage", "prod" or "cn-prod" (got: ${env})`);
45
+ export function validateEnvCnProd(env: string): 'stage' | 'prod' | 'cn-prod' | 'cn-stage' {
46
+ if (env !== 'stage' && env !== 'prod' && env !== 'cn-prod' && env !== 'cn-stage') {
47
+ throw new Error(`--env must be "stage", "prod", "cn-prod" or "cn-stage" (got: ${env})`);
45
48
  }
46
49
  return env;
47
50
  }
@@ -84,6 +87,7 @@ const skillsUrlCache: Record<string, string> = {};
84
87
 
85
88
  function getBillingUrl(env: string): string {
86
89
  if (env === 'cn-prod') return CN_PROD_BILLING_URL;
90
+ if (env === 'cn-stage') return CN_STAGE_BILLING_URL;
87
91
  if (billingUrlCache[env]) return billingUrlCache[env];
88
92
  const url = fetchInfisicalSecret(env, '/shared-secrets/domain-urls', 'BILLING_URL');
89
93
  billingUrlCache[env] = url;
@@ -100,17 +104,33 @@ function getSkillsUrl(env: string): string {
100
104
  export function getServiceToken(env: string): string {
101
105
  if (tokenCache[env]) return tokenCache[env];
102
106
 
103
- const cfg = getInfisicalConfig();
104
- const tok = getInfisicalToken(cfg);
105
- // Fetch BOTH client_id and client_secret from Infisical they differ per env.
106
- // cn-prod credentials are mirrored in the AWS Infisical *prod* environment
107
- // (fetchInfisicalSecret has no cn-prod env slug), under CN-specific keys.
108
- const isCn = env === 'cn-prod';
109
- const infisicalEnv = isCn ? 'prod' : env;
110
- const idKey = isCn ? DEV_SKILLS_CN_CLIENT_ID_KEY : DEV_SKILLS_CLIENT_ID_KEY;
111
- const secretKey = isCn ? DEV_SKILLS_CN_CLIENT_SECRET_KEY : DEV_SKILLS_CLIENT_SECRET_KEY;
112
- const clientId = fetchInfisicalSecret(infisicalEnv, DEV_SKILLS_OAUTH_PATH, idKey, cfg, tok);
113
- const clientSecret = fetchInfisicalSecret(infisicalEnv, DEV_SKILLS_OAUTH_PATH, secretKey, cfg, tok);
107
+ // cn-prod 与 cn-stage 都用 client_credentials M2M token,scope=internal:users:write。
108
+ // 凭证来源不同:
109
+ // · cn-prod —— 镜像在 AWS Infisical prod 环境(CN_PROD_ 前缀键),复用既有 AWS 访问。
110
+ // · cn-stage —— canonical cn Infisical staging /shared-secrets/oauth-clients
111
+ // (dev-skills 无该 client AWS 镜像;用 admin email/password 直读,
112
+ // 运行时需 INFISICAL_CN_EMAIL/PASSWORD,与 query-db cn 同一前提)。
113
+ const isCn = env === 'cn-prod' || env === 'cn-stage';
114
+ let clientId: string;
115
+ let clientSecret: string;
116
+ if (env === 'cn-stage') {
117
+ const cnTok = getCnInfisicalToken();
118
+ const oc = getCnSecrets(cnTok, '/shared-secrets/oauth-clients', false, 'staging');
119
+ clientId = oc['DEV_SKILLS_OAUTH_CLIENT_ID'];
120
+ clientSecret = oc['DEV_SKILLS_OAUTH_CLIENT_SECRET'];
121
+ if (!clientId || !clientSecret) {
122
+ throw new Error('cn-stage dev-skills OAuth client 凭证缺失(cn Infisical staging /shared-secrets/oauth-clients 的 DEV_SKILLS_OAUTH_CLIENT_ID/SECRET)。');
123
+ }
124
+ } else {
125
+ const cfg = getInfisicalConfig();
126
+ const tok = getInfisicalToken(cfg);
127
+ // Fetch BOTH client_id and client_secret from Infisical — they differ per env.
128
+ const infisicalEnv = env === 'cn-prod' ? 'prod' : env;
129
+ const idKey = env === 'cn-prod' ? DEV_SKILLS_CN_CLIENT_ID_KEY : DEV_SKILLS_CLIENT_ID_KEY;
130
+ const secretKey = env === 'cn-prod' ? DEV_SKILLS_CN_CLIENT_SECRET_KEY : DEV_SKILLS_CLIENT_SECRET_KEY;
131
+ clientId = fetchInfisicalSecret(infisicalEnv, DEV_SKILLS_OAUTH_PATH, idKey, cfg, tok);
132
+ clientSecret = fetchInfisicalSecret(infisicalEnv, DEV_SKILLS_OAUTH_PATH, secretKey, cfg, tok);
133
+ }
114
134
 
115
135
  const authUrl = USER_AUTH_URLS[env];
116
136
  if (!authUrl) throw new Error(`Unknown env: ${env}`);
@@ -272,6 +292,25 @@ export async function resolveUserIdByEmail(env: string, email: string): Promise<
272
292
  return parsed.user_id;
273
293
  }
274
294
 
295
+ /**
296
+ * Resolve email → userId across all envs, picking the path that exists for
297
+ * each cloud. AWS (stage/prod) reaches the database over the RDS SSH tunnel
298
+ * (db-utils.resolveUserId). cn-prod / cn-stage have no tunnel into the Aliyun
299
+ * VPC-internal RDS, so they resolve via user-auth's internal HTTP lookup
300
+ * (resolveUserIdByEmail) — the cn dev-skills token carries the
301
+ * internal:users:write scope that endpoint requires. Lets the entitlement
302
+ * subcommands support cn the same way grant-subscription already does, without
303
+ * each call site re-implementing the branch.
304
+ */
305
+ export async function resolveUserIdByEmailAnyEnv(env: string, email: string): Promise<string> {
306
+ if (env === 'cn-prod' || env === 'cn-stage') {
307
+ return resolveUserIdByEmail(env, email);
308
+ }
309
+ const cfg = getInfisicalConfig();
310
+ const token = getInfisicalToken(cfg);
311
+ return resolveUserId(email, env, cfg, token);
312
+ }
313
+
275
314
  /**
276
315
  * Resolve a user's id by phone via user-auth's internal lookup endpoint
277
316
  * (POST /api/v1/internal/users/lookup with {phone}). Mirrors
@@ -1,17 +1,21 @@
1
1
  import * as readline from 'readline';
2
2
 
3
3
  /**
4
- * On prod, print the resolved action and require typing "yes" to proceed.
5
- * No-op on stage or when --yes was passed. Exits 1 if user declines.
4
+ * On a production env, print the resolved action and require typing "yes" to
5
+ * proceed. Both AWS `prod` and Aliyun `cn-prod` are production cn-prod must
6
+ * gate identically or the cn rollout silently loses the prod safety prompt.
7
+ * No-op on stage / cn-stage or when --yes was passed. Exits 1 if user declines.
6
8
  */
9
+ const PROD_ENVS = new Set(['prod', 'cn-prod']);
10
+
7
11
  export async function confirmIfProd(
8
12
  env: string,
9
13
  actionDescription: string,
10
14
  skipFlag: boolean,
11
15
  ): Promise<void> {
12
- if (env !== 'prod' || skipFlag) return;
16
+ if (!PROD_ENVS.has(env) || skipFlag) return;
13
17
 
14
- console.log(`\n⚠️ About to perform on PROD:\n${actionDescription}\n`);
18
+ console.log(`\n⚠️ About to perform on ${env.toUpperCase()}:\n${actionDescription}\n`);
15
19
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
16
20
  const answer = await new Promise<string>((resolve) => {
17
21
  rl.question('Type "yes" to confirm: ', (a) => { rl.close(); resolve(a.trim()); });
@@ -29,15 +29,33 @@ const EC2_HOST = '3.0.210.113';
29
29
  // ─── cn-prod (阿里云) 常量 ────────────────────────────────────────────────────
30
30
  // cn-prod 跑在阿里云 SAE,与海外 AWS 完全独立:独立 Infisical (secrets-cn.optima.chat)
31
31
  // + 内网 RDS(无公网端点)经 buildbox ECS 跳板 SSH 隧道访问。设计见 optima-dev-skills#21。
32
+ // cn Infisical 是【单实例双环境】:prod=cn-prod、staging=cn-stage(URL/org/project 共享,
33
+ // 只切 environment 参数)。cn-stage 数据面共用 prod VPC,故走【同一 buildbox】跳板。
32
34
  const CN_INFISICAL_URL = process.env.INFISICAL_CN_URL || 'https://secrets-cn.optima.chat';
33
35
  const CN_INFISICAL_ORG = process.env.INFISICAL_CN_ORG || 'f44012fa-0659-4f7e-b0ac-ed01244efc8a';
34
36
  const CN_INFISICAL_PROJECT = process.env.INFISICAL_CN_PROJECT || '4deef229-11be-40a5-8f56-b61f0bce7240';
35
- const CN_RDS_HOST = 'pgm-2zexwx9eso9e4yla.pg.rds.aliyuncs.com';
37
+ const CN_RDS_HOST = 'pgm-2zexwx9eso9e4yla.pg.rds.aliyuncs.com'; // cn-prod RDS
38
+ const CN_STAGE_RDS_HOST = 'pgm-2zem1u9zdh06boim.pg.rds.aliyuncs.com'; // cn-stage RDS(独立实例,共用 prod VPC)
36
39
  const CN_BUILDBOX_HOST = process.env.OPTIMA_CN_BUILDBOX_HOST || '47.94.105.163';
37
40
 
38
- /** env 是否指向 cn-prod(阿里云)。接受 `cn` / `cn-prod`。 */
41
+ /** env 是否指向阿里云(cn-prod 或 cn-stage)。接受 `cn` / `cn-prod` / `cn-stage`。 */
39
42
  export function isCnEnv(env: string): boolean {
40
- return env === 'cn' || env === 'cn-prod';
43
+ return env === 'cn' || env === 'cn-prod' || env === 'cn-stage';
44
+ }
45
+
46
+ /** env 是否指向 cn-stage(阿里云预发)。 */
47
+ export function isCnStageEnv(env: string): boolean {
48
+ return env === 'cn-stage';
49
+ }
50
+
51
+ /** cn env → cn Infisical environment slug。cn-stage→staging,其余 cn→prod。 */
52
+ export function cnInfisicalEnv(env: string): 'prod' | 'staging' {
53
+ return env === 'cn-stage' ? 'staging' : 'prod';
54
+ }
55
+
56
+ /** cn env → 内网 RDS host。cn-stage→stage 实例,其余 cn→prod 实例。 */
57
+ export function cnRdsHost(env: string): string {
58
+ return env === 'cn-stage' ? CN_STAGE_RDS_HOST : CN_RDS_HOST;
41
59
  }
42
60
 
43
61
  // ─── SQL escaping ───────────────────────────────────────────────────────────
@@ -117,10 +135,13 @@ export function getCnInfisicalToken(): string {
117
135
  return org.token;
118
136
  }
119
137
 
120
- /** 读 cn Infisical 某 folder(prod env)→ key→value map。expand=true 让服务端解析 `${...}` 引用。 */
121
- export function getCnSecrets(token: string, secretPath: string, expand = false): Record<string, string> {
138
+ /**
139
+ * cn Infisical folder key→value map。expand=true 让服务端解析 `${...}` 引用。
140
+ * environment:cn-prod 用 `prod`、cn-stage 用 `staging`(同一实例双环境)。
141
+ */
142
+ export function getCnSecrets(token: string, secretPath: string, expand = false, environment: 'prod' | 'staging' = 'prod'): Record<string, string> {
122
143
  const data = curlJson([
123
- `${CN_INFISICAL_URL}/api/v3/secrets/raw?workspaceId=${CN_INFISICAL_PROJECT}&environment=prod&secretPath=${encodeURIComponent(secretPath)}&expandSecretReferences=${expand}`,
144
+ `${CN_INFISICAL_URL}/api/v3/secrets/raw?workspaceId=${CN_INFISICAL_PROJECT}&environment=${environment}&secretPath=${encodeURIComponent(secretPath)}&expandSecretReferences=${expand}`,
124
145
  '-H', `Authorization: Bearer ${token}`,
125
146
  ]);
126
147
  const secrets: Record<string, string> = {};
@@ -417,17 +438,18 @@ export function queryDB(conn: DBConnection, sql: string): string {
417
438
  * 连 cn-prod 某服务库:creds 取自 cn Infisical 的 /shared-secrets/database-users +
418
439
  * /database-names(按 prefix,如 AUTH/BILLING),经 buildbox 跳板隧道(动态端口)。
419
440
  */
420
- export function connectCnDB(prefix: string): { query: (sql: string) => string } {
441
+ export function connectCnDB(prefix: string, cnEnv: string = 'cn-prod'): { query: (sql: string) => string } {
442
+ const infEnv = cnInfisicalEnv(cnEnv);
421
443
  const token = getCnInfisicalToken();
422
- const users = getCnSecrets(token, '/shared-secrets/database-users');
423
- const names = getCnSecrets(token, '/shared-secrets/database-names');
444
+ const users = getCnSecrets(token, '/shared-secrets/database-users', false, infEnv);
445
+ const names = getCnSecrets(token, '/shared-secrets/database-names', false, infEnv);
424
446
  const user = users[`${prefix}_DB_USER`];
425
447
  const password = users[`${prefix}_DB_PASSWORD`];
426
448
  const database = names[`${prefix}_DB_NAME`];
427
449
  if (!user || !password || !database) {
428
450
  throw new Error(`cn DB creds 不全 for ${prefix}(需 ${prefix}_DB_USER/PASSWORD@database-users + ${prefix}_DB_NAME@database-names)。该服务 cn 可能用字面 DATABASE_URL,见 optima-dev-skills#21。`);
429
451
  }
430
- const port = ensureTunnel(CN_RDS_HOST, 'cn-buildbox');
452
+ const port = ensureTunnel(cnRdsHost(cnEnv), 'cn-buildbox');
431
453
  const conn: DBConnection = { host: 'localhost', port, user, password, database };
432
454
  return { query: (sql: string) => queryDB(conn, sql) };
433
455
  }
@@ -437,9 +459,9 @@ export function connectCnDB(prefix: string): { query: (sql: string) => string }
437
459
  * 用于 cred 不在 shared-secrets/database-users 的服务(如 gateway-core)。
438
460
  * expandSecretReferences=true 让 cn Infisical 解析 `${...}` 引用为字面值。
439
461
  */
440
- export function connectCnDBFromUrl(servicePath: string): { query: (sql: string) => string } {
462
+ export function connectCnDBFromUrl(servicePath: string, cnEnv: string = 'cn-prod'): { query: (sql: string) => string } {
441
463
  const token = getCnInfisicalToken();
442
- const secrets = getCnSecrets(token, servicePath, true);
464
+ const secrets = getCnSecrets(token, servicePath, true, cnInfisicalEnv(cnEnv));
443
465
  const dbUrl = secrets['DATABASE_URL'];
444
466
  if (!dbUrl) throw new Error(`cn DATABASE_URL 未找到 at ${servicePath}`);
445
467
  // 注意:DATABASE_URL 含凭证,报错只说事实,不回显值
@@ -1,4 +1,4 @@
1
- import { callBilling, validateEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
3
 
4
4
  interface CreateArgs {
@@ -34,7 +34,7 @@ Optional:
34
34
  --ends <date> Valid-until
35
35
  --max <N> Max total redemptions (default: unlimited; 1 = single-use)
36
36
  --campaign <label> Grouping label
37
- --env stage|prod (default: stage)
37
+ --env stage|prod|cn-prod|cn-stage (default: stage)
38
38
  --yes Skip prod confirmation`);
39
39
  process.exit(0);
40
40
  }
@@ -62,7 +62,7 @@ Optional:
62
62
 
63
63
  export async function runCreate(argv: string[]): Promise<void> {
64
64
  const args = parseArgs(argv);
65
- validateEnv(args.env);
65
+ validateEnvCnProd(args.env);
66
66
  await confirmIfProd(args.env, `Create discount code ${args.code.toUpperCase()} (${args.percentOff}% off)`, args.yes);
67
67
 
68
68
  const body: Record<string, unknown> = { code: args.code, percentOff: args.percentOff };
@@ -1,4 +1,4 @@
1
- import { callBilling, validateEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
3
 
4
4
  interface DisableArgs {
@@ -15,7 +15,7 @@ Required:
15
15
  --code <CODE>
16
16
 
17
17
  Optional:
18
- --env stage|prod (default: stage)
18
+ --env stage|prod|cn-prod|cn-stage (default: stage)
19
19
  --yes Skip prod confirmation`);
20
20
  process.exit(0);
21
21
  }
@@ -36,7 +36,7 @@ Optional:
36
36
 
37
37
  export async function runDisable(argv: string[]): Promise<void> {
38
38
  const args = parseArgs(argv);
39
- validateEnv(args.env);
39
+ validateEnvCnProd(args.env);
40
40
  await confirmIfProd(args.env, `Disable discount code ${args.code.toUpperCase()}`, args.yes);
41
41
  const res = await callBilling(args.env, 'PATCH', `/api/billing/admin/discount-codes/${encodeURIComponent(args.code)}`, { status: 'DISABLED' });
42
42
  console.log(`✓ Disabled (HTTP ${res.status}):`);
@@ -1,5 +1,5 @@
1
1
  import * as fs from 'fs';
2
- import { callBilling, validateEnv } from '../billing-http';
2
+ import { callBilling, validateEnvCnProd } from '../billing-http';
3
3
  import { confirmIfProd } from '../confirm-prompt';
4
4
 
5
5
  interface GenArgs {
@@ -35,7 +35,7 @@ Optional:
35
35
  --products <a,b,...> Limit to these productKeys
36
36
  --starts <date> Valid-from (YYYY-MM-DD or ISO)
37
37
  --ends <date> Valid-until
38
- --env stage|prod (default: stage)
38
+ --env stage|prod|cn-prod|cn-stage (default: stage)
39
39
  --yes Skip prod confirmation`);
40
40
  process.exit(0);
41
41
  }
@@ -63,7 +63,7 @@ Optional:
63
63
 
64
64
  export async function runGenerate(argv: string[]): Promise<void> {
65
65
  const args = parseArgs(argv);
66
- validateEnv(args.env);
66
+ validateEnvCnProd(args.env);
67
67
  await confirmIfProd(args.env, `Generate ${args.count} unique discount codes (${args.percentOff}% off, campaign ${args.campaign})`, args.yes);
68
68
 
69
69
  const body: Record<string, unknown> = { count: args.count, percentOff: args.percentOff, campaign: args.campaign };
@@ -1,4 +1,4 @@
1
- import { callBilling, validateEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd } from '../billing-http';
2
2
 
3
3
  interface ListArgs {
4
4
  campaign?: string;
@@ -15,7 +15,7 @@ Optional:
15
15
  --campaign <label> Filter by campaign
16
16
  --code <CODE> Filter by exact code
17
17
  --limit <N> Max rows (default 500, max 1000)
18
- --env stage|prod (default: stage)`);
18
+ --env stage|prod|cn-prod|cn-stage (default: stage)`);
19
19
  process.exit(0);
20
20
  }
21
21
  const out: Partial<ListArgs> = { env: 'stage' };
@@ -35,7 +35,7 @@ Optional:
35
35
 
36
36
  export async function runList(argv: string[]): Promise<void> {
37
37
  const args = parseArgs(argv);
38
- validateEnv(args.env);
38
+ validateEnvCnProd(args.env);
39
39
  const qs = new URLSearchParams();
40
40
  if (args.campaign) qs.set('campaign', args.campaign);
41
41
  if (args.code) qs.set('code', args.code);
@@ -1,6 +1,5 @@
1
- import { callBilling, validateEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd, resolveUserIdByEmailAnyEnv } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
- import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
4
3
 
5
4
  interface GrantArgs {
6
5
  email: string;
@@ -21,7 +20,7 @@ Required:
21
20
 
22
21
  Optional:
23
22
  --yes Skip prod confirmation prompt (no-op on stage)
24
- --env stage|prod (default: stage)
23
+ --env stage|prod|cn-prod|cn-stage (default: stage)
25
24
 
26
25
  Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
27
26
  process.exit(0);
@@ -47,11 +46,9 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
47
46
 
48
47
  export async function runGrant(argv: string[]): Promise<void> {
49
48
  const args = parseArgs(argv);
50
- validateEnv(args.env);
49
+ validateEnvCnProd(args.env);
51
50
 
52
- const cfg = getInfisicalConfig();
53
- const token = getInfisicalToken(cfg);
54
- const userId = await resolveUserId(args.email, args.env, cfg, token);
51
+ const userId = await resolveUserIdByEmailAnyEnv(args.env, args.email);
55
52
 
56
53
  await confirmIfProd(
57
54
  args.env,
@@ -1,5 +1,4 @@
1
- import { callBilling, validateEnv } from '../billing-http';
2
- import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
1
+ import { callBilling, validateEnvCnProd, resolveUserIdByEmailAnyEnv } from '../billing-http';
3
2
 
4
3
  interface ListArgs {
5
4
  email: string;
@@ -14,7 +13,7 @@ Required:
14
13
  --email <user-email> Resolved to userId via user-auth DB
15
14
 
16
15
  Optional:
17
- --env stage|prod (default: stage)`);
16
+ --env stage|prod|cn-prod|cn-stage (default: stage)`);
18
17
  process.exit(0);
19
18
  }
20
19
  const out: Partial<ListArgs> = { env: 'stage' };
@@ -42,11 +41,9 @@ interface EntitlementRow {
42
41
 
43
42
  export async function runList(argv: string[]): Promise<void> {
44
43
  const args = parseArgs(argv);
45
- validateEnv(args.env);
44
+ validateEnvCnProd(args.env);
46
45
 
47
- const cfg = getInfisicalConfig();
48
- const token = getInfisicalToken(cfg);
49
- const userId = await resolveUserId(args.email, args.env, cfg, token);
46
+ const userId = await resolveUserIdByEmailAnyEnv(args.env, args.email);
50
47
 
51
48
  const res = await callBilling<{ entitlements: EntitlementRow[] }>(
52
49
  args.env,
@@ -1,6 +1,5 @@
1
- import { callBilling, validateEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd, resolveUserIdByEmailAnyEnv } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
- import { getInfisicalConfig, getInfisicalToken, resolveUserId } from '../db-utils';
4
3
 
5
4
  interface RevokeArgs {
6
5
  email: string;
@@ -28,7 +27,7 @@ Required:
28
27
 
29
28
  Optional:
30
29
  --yes Skip prod confirmation prompt (no-op on stage)
31
- --env stage|prod (default: stage)
30
+ --env stage|prod|cn-prod|cn-stage (default: stage)
32
31
 
33
32
  Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
34
33
  error pointing to the right reversal flow.`);
@@ -59,11 +58,9 @@ const PARTNER_REFUSAL = `refusing to revoke a PARTNER-source entitlement via CLI
59
58
 
60
59
  export async function runRevoke(argv: string[]): Promise<void> {
61
60
  const args = parseArgs(argv);
62
- validateEnv(args.env);
61
+ validateEnvCnProd(args.env);
63
62
 
64
- const cfg = getInfisicalConfig();
65
- const token = getInfisicalToken(cfg);
66
- const userId = await resolveUserId(args.email, args.env, cfg, token);
63
+ const userId = await resolveUserIdByEmailAnyEnv(args.env, args.email);
67
64
 
68
65
  // Step 1: Fetch user's entitlements
69
66
  const listRes = await callBilling<{ entitlements: EntitlementRow[] }>(
@@ -24,7 +24,7 @@ interface MerchantSetupResponse {
24
24
  user_id: string;
25
25
  }
26
26
 
27
- type Environment = 'ci' | 'stage' | 'prod' | 'cn-prod';
27
+ type Environment = 'ci' | 'stage' | 'prod' | 'cn-prod' | 'cn-stage';
28
28
 
29
29
  interface EnvironmentConfig {
30
30
  authUrl: string;
@@ -60,6 +60,14 @@ const ENV_CONFIG: Record<Environment, EnvironmentConfig> = {
60
60
  // 注意不要换成同名的 confidential client `commerce-cli-cn-prod-*`,那个需要 client_secret,走不通 CLI 的 public ROPC flow。
61
61
  clientId: 'dev-skill-cli-cn-pro-acvkmcuq',
62
62
  envName: 'cn-prod'
63
+ },
64
+ 'cn-stage': {
65
+ // 阿里云预发(独立于 AWS stage `.optima.onl`)。域名 *.stage.optima.chat。
66
+ authUrl: 'https://auth.stage.optima.chat',
67
+ apiUrl: 'https://commerce.stage.optima.chat',
68
+ // cn-stage user-auth 里注册的 public client(开 password grant,ROPC flow)。
69
+ clientId: 'dev-skill-cli-cn-sta-3dvsxzdo',
70
+ envName: 'cn-stage'
63
71
  }
64
72
  };
65
73
 
@@ -183,6 +191,7 @@ async function main() {
183
191
  let phone: string | undefined;
184
192
  let address: string | undefined;
185
193
  let environment: Environment = 'ci';
194
+ let skipMerchant = false;
186
195
 
187
196
  // 解析命令行参数
188
197
  for (let i = 0; i < args.length; i++) {
@@ -198,12 +207,14 @@ async function main() {
198
207
  phone = args[++i];
199
208
  } else if (arg === '--address' && args[i + 1]) {
200
209
  address = args[++i];
210
+ } else if (arg === '--skip-merchant') {
211
+ skipMerchant = true;
201
212
  } else if (arg === '--env' && args[i + 1]) {
202
213
  const envArg = args[++i];
203
- if (envArg === 'ci' || envArg === 'stage' || envArg === 'prod' || envArg === 'cn-prod') {
214
+ if (envArg === 'ci' || envArg === 'stage' || envArg === 'prod' || envArg === 'cn-prod' || envArg === 'cn-stage') {
204
215
  environment = envArg as Environment;
205
216
  } else {
206
- console.error(`❌ Invalid environment: ${envArg}. Must be 'ci', 'stage', 'prod', or 'cn-prod'.`);
217
+ console.error(`❌ Invalid environment: ${envArg}. Must be 'ci', 'stage', 'prod', 'cn-prod', or 'cn-stage'.`);
207
218
  process.exit(1);
208
219
  }
209
220
  } else if (arg === '--help' || arg === '-h') {
@@ -216,20 +227,24 @@ Options:
216
227
  --business-name <name> Merchant business name (default: auto-generated)
217
228
  --phone <phone> Merchant phone number (optional)
218
229
  --address <address> Merchant address (optional)
219
- --env <environment> Environment: ci (default), stage, prod, or cn-prod
230
+ --skip-merchant 只注册用户 + token,跳过 Commerce merchant profile 设置
231
+ (token-only;用于 gateway E2E、或 commerce 未就绪的环境)
232
+ --env <environment> Environment: ci (default), stage, prod, cn-prod, or cn-stage
220
233
  --help, -h Show this help message
221
234
 
222
235
  Environments:
223
- ci CI 环境 (auth.optima.chat, api.optima.chat)
224
- stage Stage 环境 (auth.stage.optima.onl, api.stage.optima.onl)
225
- prod Prod 环境 (auth.optima.onl, api.optima.onl)
226
- cn-prod 阿里云生产环境 (auth.yzsgo.com, commerce.yzsgo.com) [#201]
236
+ ci CI 环境 (auth.optima.chat, api.optima.chat)
237
+ stage Stage 环境 (auth.stage.optima.onl, api.stage.optima.onl)
238
+ prod Prod 环境 (auth.optima.onl, api.optima.onl)
239
+ cn-prod 阿里云生产环境 (auth.yzsgo.com, commerce.yzsgo.com) [#201]
240
+ cn-stage 阿里云预发环境 (auth.stage.optima.chat, commerce.stage.optima.chat)
227
241
 
228
242
  Example:
229
243
  optima-generate-test-token
230
244
  optima-generate-test-token --env stage
231
245
  optima-generate-test-token --env prod
232
246
  optima-generate-test-token --env cn-prod
247
+ optima-generate-test-token --env cn-stage
233
248
  optima-generate-test-token --business-name "My Test Shop" --env stage
234
249
  `);
235
250
  process.exit(0);
@@ -254,8 +269,11 @@ Example:
254
269
  // 2. 获取 token
255
270
  const token = await getToken(email, password, config);
256
271
 
257
- // 3. 设置 merchant profile(Commerce API)
258
- const merchantProfile = await setupMerchantProfile(token, businessName, config);
272
+ // 3. 设置 merchant profile(Commerce API);--skip-merchant 时跳过(token-only
273
+ const merchantProfile = skipMerchant
274
+ ? { merchant_id: 'N/A (skipped)' } as MerchantSetupResponse
275
+ : await setupMerchantProfile(token, businessName, config);
276
+ if (skipMerchant) console.log('\n⏭️ Skipped merchant profile setup (--skip-merchant)');
259
277
 
260
278
  // 4. 保存 token 到临时文件
261
279
  const tmpDir = os.tmpdir();
@@ -19,13 +19,14 @@ $1 = 700 credits (P15 unified ledger; the USD wallet is retired).
19
19
  Options:
20
20
  --amount <usd> USD amount to grant (required, e.g. 5 for $5.00 = 3500 credits)
21
21
  --description <text> Description for audit trail (optional)
22
- --env <env> Environment: stage, prod, cn-prod (default: stage)
22
+ --env <env> Environment: stage, prod, cn-prod, cn-stage (default: stage)
23
23
  -h, --help Show this help
24
24
 
25
25
  Examples:
26
26
  optima-grant-balance user@example.com --amount 5 --env prod
27
27
  optima-grant-balance user@example.com --amount 10 --description "Service outage compensation"
28
- optima-grant-balance user@example.com --amount 1 --env cn-prod # ¥-priced env, still USD input ($1 = 700 credits)`);
28
+ optima-grant-balance user@example.com --amount 1 --env cn-prod # ¥-priced env, still USD input ($1 = 700 credits)
29
+ optima-grant-balance user@example.com --amount 1 --env cn-stage # 阿里云预发`);
29
30
  process.exit(0);
30
31
  }
31
32
 
@@ -55,10 +56,10 @@ async function main() {
55
56
  console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${email} [${env.toUpperCase()}]\n`);
56
57
  if (description) console.log(` Reason: ${description}`);
57
58
 
58
- // cn-prod has no SSH tunnel into the Aliyun RDS — resolve via user-auth's
59
- // internal lookup API instead of the direct SQL path.
59
+ // cn-prod / cn-stage have no SSH tunnel into the Aliyun RDS — resolve via
60
+ // user-auth's internal lookup API instead of the direct SQL path.
60
61
  let userId: string;
61
- if (env === 'cn-prod') {
62
+ if (env === 'cn-prod' || env === 'cn-stage') {
62
63
  userId = await resolveUserIdByEmail(env, email);
63
64
  } else {
64
65
  const infisicalConfig = getInfisicalConfig();