@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
@@ -60,7 +60,7 @@ export function assertPhoneMatch(inputPhone: string, accountPhone: string | null
60
60
  */
61
61
  export function assertAwsEmailOnly(env: string, kind: 'email' | 'phone' | 'userId'): void {
62
62
  if ((env === 'stage' || env === 'prod') && kind !== 'email') {
63
- throw new Error('stage/prod 仅支持 email 标识;手机号/userId 解析仅 cn-prod 可用');
63
+ throw new Error('stage/prod 仅支持 email 标识;手机号/userId 解析仅 cn-prod / cn-stage 可用');
64
64
  }
65
65
  }
66
66
 
@@ -71,6 +71,8 @@ const PLANS_BY_ENV: Record<string, string[]> = {
71
71
  stage: ['trial', 'starter', 'pro', 'enterprise'],
72
72
  prod: ['trial', 'starter', 'pro', 'enterprise'],
73
73
  'cn-prod': ['trial', 'starter-cn', 'pro-cn', 'enterprise-cn'],
74
+ // cn-stage 卖同一套 CNY -cn plan(billing plan 目录与 cn-prod 一致)
75
+ 'cn-stage': ['trial', 'starter-cn', 'pro-cn', 'enterprise-cn'],
74
76
  };
75
77
 
76
78
  function parseArgs(args: string[]): { identifier: string; plan: string; months: number; env: string } {
@@ -79,9 +81,9 @@ function parseArgs(args: string[]): { identifier: string; plan: string; months:
79
81
 
80
82
  Options:
81
83
  --plan <id> Plan: trial, starter, pro, enterprise (default: pro)
82
- cn-prod plans: trial, starter-cn, pro-cn, enterprise-cn (default: pro-cn)
84
+ cn-prod/cn-stage plans: trial, starter-cn, pro-cn, enterprise-cn (default: pro-cn)
83
85
  --months <n> Duration in months (default: 1)
84
- --env <env> Environment: stage, prod, cn-prod (default: stage)
86
+ --env <env> Environment: stage, prod, cn-prod, cn-stage (default: stage)
85
87
  -h, --help Show this help`);
86
88
  process.exit(0);
87
89
  }
@@ -98,7 +100,7 @@ Options:
98
100
  }
99
101
 
100
102
  validateEnvCnProd(env);
101
- plan = plan ?? (env === 'cn-prod' ? 'pro-cn' : 'pro');
103
+ plan = plan ?? (env === 'cn-prod' || env === 'cn-stage' ? 'pro-cn' : 'pro');
102
104
  const allowed = PLANS_BY_ENV[env];
103
105
  if (!allowed.includes(plan)) {
104
106
  console.error(`Unknown plan for ${env}: ${plan}. Available: ${allowed.join(', ')}`);
@@ -118,13 +120,13 @@ async function main() {
118
120
 
119
121
  // identity is the verified target account to print on success. AWS keeps the
120
122
  // pre-change behavior (email-only, no internal HTTP reverse-verify); only
121
- // cn-prod runs the full classify→resolve→getUserById→phone-assert防呆 path.
123
+ // cn-prod / cn-stage run the full classify→resolve→getUserById→phone-assert防呆 path.
122
124
  let userId: string;
123
125
  let identity: { phone: string | null; email: string | null };
124
126
 
125
- if (env === 'cn-prod') {
126
- // cn-prod has no SSH tunnel into the Aliyun RDS — resolve via user-auth,
127
- // and its dev-skills token carries internal:users:write for the lookups.
127
+ if (env === 'cn-prod' || env === 'cn-stage') {
128
+ // cn-prod / cn-stage have no SSH tunnel into the Aliyun RDS — resolve via
129
+ // user-auth, and the dev-skills token carries internal:users:write for lookups.
128
130
  if (kind === 'userId') {
129
131
  userId = identifier;
130
132
  } else if (kind === 'phone') {
@@ -157,7 +157,7 @@ async function main() {
157
157
  console.error('Usage: query-db.ts <service> <sql> [environment]');
158
158
  console.error('');
159
159
  console.error('Services: commerce-backend, user-auth, agentic-chat, bi-backend, session-gateway, gateway-core, optima-logistics, billing, ads-backend, amazon-backend, browser-backend, shopify-backend, optima-generation, optima-sentinel');
160
- console.error('Environments: ci (default), stage, prod, cn (阿里云 cn-prod)');
160
+ console.error('Environments: ci (default), stage, prod, cn-prod (阿里云生产), cn-stage (阿里云预发)');
161
161
  console.error('');
162
162
  console.error('Example: query-db.ts user-auth "SELECT COUNT(*) FROM users" prod');
163
163
  process.exit(1);
@@ -174,17 +174,20 @@ async function main() {
174
174
  // cn-prod(阿里云):独立 Infisical + 经 buildbox 跳板连内网 RDS(动态端口隧道)。
175
175
  // 两类 cred:① shared-secrets/database-users(按 prefix)② 服务自己的 DATABASE_URL(展开引用)。
176
176
  if (isCnEnv(environment)) {
177
+ // cn-prod / cn-stage 共用 SERVICE_DB_MAP[*].prod 的 userKey/databaseUrlPath(prefix、
178
+ // /services/<svc> 路径两环境一致);connectCn* 按 env 切 cn Infisical 环境 + RDS 实例。
179
+ const label = environment === 'cn-stage' ? 'CN-STAGE' : 'CN-PROD';
177
180
  const prodCfg = SERVICE_DB_MAP[service as keyof typeof SERVICE_DB_MAP].prod as any;
178
181
  let db: { query: (sql: string) => string };
179
182
  if (prodCfg?.userKey) {
180
183
  const prefix = prodCfg.userKey.replace(/_DB_USER$/, '');
181
- console.log(`\n🔍 Querying ${service} (CN-PROD, prefix ${prefix})...`);
182
- db = connectCnDB(prefix);
184
+ console.log(`\n🔍 Querying ${service} (${label}, prefix ${prefix})...`);
185
+ db = connectCnDB(prefix, environment);
183
186
  } else if (prodCfg?.databaseUrlPath) {
184
- console.log(`\n🔍 Querying ${service} (CN-PROD, DATABASE_URL @ ${prodCfg.databaseUrlPath})...`);
185
- db = connectCnDBFromUrl(prodCfg.databaseUrlPath);
187
+ console.log(`\n🔍 Querying ${service} (${label}, DATABASE_URL @ ${prodCfg.databaseUrlPath})...`);
188
+ db = connectCnDBFromUrl(prodCfg.databaseUrlPath, environment);
186
189
  } else {
187
- console.error(`cn-prod query 暂不支持 ${service}(既无 userKey 也无 databaseUrlPath)。见 optima-dev-skills#21。`);
190
+ console.error(`${label} query 暂不支持 ${service}(既无 userKey 也无 databaseUrlPath)。见 optima-dev-skills#21。`);
188
191
  process.exit(1);
189
192
  }
190
193
  console.log('\n' + db.query(sql));
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execSync } from 'child_process';
4
- import { getInfisicalConfig, getInfisicalToken, InfisicalConfig } from './db-utils';
4
+ import { getInfisicalConfig, getInfisicalToken, InfisicalConfig, isCnEnv, cnInfisicalEnv, getCnInfisicalToken, getCnSecrets } from './db-utils';
5
5
 
6
6
  // 支持的服务列表(Infisical 路径为 /services/<service-name>)
7
7
  const SUPPORTED_SERVICES = [
@@ -32,12 +32,17 @@ const SUPPORTED_SERVICES = [
32
32
  'shopify-backend',
33
33
  ];
34
34
 
35
- // 环境到 Infisical environment 的映射
35
+ // 环境到 AWS Infisical environment 的映射(cn 走独立 cn Infisical,见下方分支)
36
36
  const ENV_MAP: Record<string, string> = {
37
37
  stage: 'staging',
38
38
  prod: 'prod'
39
39
  };
40
40
 
41
+ /** 该 environment 是否被支持(AWS stage/prod 或阿里云 cn-prod/cn-stage)。 */
42
+ function isSupportedEnv(env: string): boolean {
43
+ return ENV_MAP[env] !== undefined || isCnEnv(env);
44
+ }
45
+
41
46
  // NOTE: getInfisicalSecrets is kept local because it encodes secretPath (encodeURIComponent),
42
47
  // unlike db-utils' raw-path variant; getGitHubVariable/getInfisicalConfig/getInfisicalToken are shared from db-utils.
43
48
  function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record<string, string> {
@@ -60,17 +65,22 @@ Usage: optima-show-env <service> <environment> [options]
60
65
 
61
66
  Arguments:
62
67
  service Service name (${SUPPORTED_SERVICES.join(', ')})
63
- environment Environment (stage, prod)
68
+ environment Environment (stage, prod, cn-prod, cn-stage)
64
69
 
65
70
  Options:
66
71
  --filter Filter by key pattern (e.g., --filter DATABASE)
67
72
  --keys-only Show only key names, not values
68
73
  --help Show this help message
69
74
 
75
+ Note:
76
+ cn-prod / cn-stage 读阿里云 cn Infisical(需 INFISICAL_CN_EMAIL/PASSWORD 环境变量,
77
+ admin user,见 optima-dev-skills#21)。stage/prod 读 AWS Infisical(GitHub Variables)。
78
+
70
79
  Examples:
71
80
  optima-show-env commerce-backend stage
72
81
  optima-show-env user-auth prod --filter DATABASE
73
- optima-show-env bi stage --keys-only
82
+ optima-show-env gateway-core cn-stage
83
+ optima-show-env bi-backend stage --keys-only
74
84
  `);
75
85
  }
76
86
 
@@ -114,9 +124,9 @@ async function main() {
114
124
  }
115
125
 
116
126
  // 验证环境
117
- if (!ENV_MAP[environment]) {
127
+ if (!isSupportedEnv(environment)) {
118
128
  console.error(`Error: Unknown environment '${environment}'`);
119
- console.error('Available environments: stage, prod');
129
+ console.error('Available environments: stage, prod, cn-prod, cn-stage');
120
130
  process.exit(1);
121
131
  }
122
132
 
@@ -126,15 +136,24 @@ async function main() {
126
136
  console.log(`\n🔍 Fetching environment variables for ${service} (${environment.toUpperCase()})...\n`);
127
137
 
128
138
  try {
129
- const infisicalConfig = getInfisicalConfig();
130
- console.log('✓ Loaded Infisical config from GitHub Variables');
131
-
132
- const token = getInfisicalToken(infisicalConfig);
133
- console.log('✓ Obtained Infisical access token');
134
-
135
- const infisicalEnv = ENV_MAP[environment];
136
- const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, secretPath);
137
- console.log(`✓ Retrieved secrets from Infisical (path: ${secretPath})\n`);
139
+ let secrets: Record<string, string>;
140
+ if (isCnEnv(environment)) {
141
+ // 阿里云 cn Infisical(独立实例,admin email/password 认证;prod / staging 双环境)
142
+ const cnToken = getCnInfisicalToken();
143
+ console.log('✓ Obtained cn Infisical access token');
144
+ secrets = getCnSecrets(cnToken, secretPath, false, cnInfisicalEnv(environment));
145
+ console.log(`✓ Retrieved secrets from cn Infisical (env: ${cnInfisicalEnv(environment)}, path: ${secretPath})\n`);
146
+ } else {
147
+ const infisicalConfig = getInfisicalConfig();
148
+ console.log('✓ Loaded Infisical config from GitHub Variables');
149
+
150
+ const token = getInfisicalToken(infisicalConfig);
151
+ console.log('✓ Obtained Infisical access token');
152
+
153
+ const infisicalEnv = ENV_MAP[environment];
154
+ secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, secretPath);
155
+ console.log(`✓ Retrieved secrets from Infisical (path: ${secretPath})\n`);
156
+ }
138
157
 
139
158
  const keys = Object.keys(secrets).sort();
140
159
 
@@ -29,22 +29,23 @@ import { promises as dns } from 'node:dns';
29
29
  import * as tls from 'node:tls';
30
30
  import * as https from 'node:https';
31
31
 
32
- type Env = 'stage' | 'prod' | 'cn';
33
- interface SvcCfg { path: string; stage?: string; prod?: string; cn?: string; cn_path?: string; }
32
+ type Env = 'stage' | 'prod' | 'cn' | 'cn-stage';
33
+ interface SvcCfg { path: string; stage?: string; prod?: string; cn?: string; cn_path?: string; 'cn-stage'?: string; 'cn-stage_path'?: string; }
34
34
 
35
35
  // 服务 × 环境 FQDN 表。某服务某环境没部署 → 该 env 键缺省,探时跳过。
36
36
  // cn-prod 真实 subdomain 抄自 optima-terraform alicloud/stacks/cn-prod-ingress-sae/main.tf。
37
37
  // #201 (2026-06-12): yzsgo.com 全量迁移完成,旧 *-cn.optima.chat 路由已下线。
38
+ // cn-stage(阿里云预发)域名 *.stage.optima.chat,抄自 cn-stage-ingress-sae services map。
38
39
  const SERVICES: Record<string, SvcCfg> = {
39
- 'user-auth': { path: '/health', stage: 'auth-stage.optima.onl', prod: 'auth.optima.onl', cn: 'auth.yzsgo.com' },
40
- 'agentic-chat': { path: '/api/health', stage: 'ai-stage.optima.onl', prod: 'ai.optima.onl', cn: 'app.yzsgo.com' },
41
- 'commerce-backend': { path: '/health', stage: 'api-stage.optima.onl', prod: 'api.optima.onl', cn: 'commerce.yzsgo.com', cn_path: '/health/live' },
40
+ 'user-auth': { path: '/health', stage: 'auth-stage.optima.onl', prod: 'auth.optima.onl', cn: 'auth.yzsgo.com', 'cn-stage': 'auth.stage.optima.chat' },
41
+ 'agentic-chat': { path: '/api/health', stage: 'ai-stage.optima.onl', prod: 'ai.optima.onl', cn: 'app.yzsgo.com', 'cn-stage': 'app.stage.optima.chat' },
42
+ 'commerce-backend': { path: '/health', stage: 'api-stage.optima.onl', prod: 'api.optima.onl', cn: 'commerce.yzsgo.com', cn_path: '/health/live', 'cn-stage': 'commerce.stage.optima.chat', 'cn-stage_path': '/health/live' },
42
43
  'mcp-host': { path: '/health', stage: 'mcp-stage.optima.onl', prod: 'mcp.optima.onl' },
43
- 'gateway-core': { path: '/health', cn: 'gw.yzsgo.com' },
44
- 'optima-scout': { path: '/health', cn: 'scout.yzsgo.com' },
45
- 'optima-skills': { path: '/health', cn: 'skills.yzsgo.com' },
44
+ 'gateway-core': { path: '/health', cn: 'gw.yzsgo.com', 'cn-stage': 'gw.stage.optima.chat' },
45
+ 'optima-scout': { path: '/health', cn: 'scout.yzsgo.com', 'cn-stage': 'scout.stage.optima.chat' },
46
+ 'optima-skills': { path: '/health', cn: 'skills.yzsgo.com', 'cn-stage': 'skills.stage.optima.chat' },
46
47
  };
47
- const ENVS: Env[] = ['stage', 'prod', 'cn'];
48
+ const ENVS: Env[] = ['stage', 'prod', 'cn', 'cn-stage'];
48
49
 
49
50
  const G = '\x1b[32m', R = '\x1b[31m', Y = '\x1b[33m', B = '\x1b[34m', N = '\x1b[0m';
50
51
  const MARK: Record<string, string> = { ok: `${G}✅${N}`, fail: `${R}❌${N}`, warn: `${Y}⚠️ ${N}`, na: `${B}··${N}` };
@@ -6,6 +6,7 @@ exports.getServiceToken = getServiceToken;
6
6
  exports.callBilling = callBilling;
7
7
  exports.callSkills = callSkills;
8
8
  exports.resolveUserIdByEmail = resolveUserIdByEmail;
9
+ exports.resolveUserIdByEmailAnyEnv = resolveUserIdByEmailAnyEnv;
9
10
  exports.resolveUserIdByPhone = resolveUserIdByPhone;
10
11
  exports.getUserById = getUserById;
11
12
  const child_process_1 = require("child_process");
@@ -16,12 +17,15 @@ const USER_AUTH_URLS = {
16
17
  prod: 'https://auth.optima.onl',
17
18
  // #201: yzsgo.com 全量迁移 (2026-06-12), 旧 auth-cn.optima.chat 路由已下线
18
19
  'cn-prod': 'https://auth.yzsgo.com',
20
+ // cn-stage(阿里云预发,独立于 AWS stage `.optima.onl`)
21
+ 'cn-stage': 'https://auth.stage.optima.chat',
19
22
  };
20
- // cn-prod URLs are hardcoded: cn Infisical (secrets-cn.optima.chat) is a
23
+ // cn URLs are hardcoded: cn Infisical (secrets-cn.optima.chat) is a
21
24
  // separate instance dev-skills has no machine identity for, and these domains
22
25
  // are stable. AWS envs keep reading /shared-secrets/domain-urls.
23
26
  // #201: yzsgo.com 全量迁移 (2026-06-12), 旧 billing-cn.optima.chat 路由已下线
24
27
  const CN_PROD_BILLING_URL = 'https://billing-api.yzsgo.com';
28
+ const CN_STAGE_BILLING_URL = 'https://billing-api.stage.optima.chat';
25
29
  /**
26
30
  * Validate the --env flag value at command entry, before any I/O.
27
31
  *
@@ -46,8 +50,8 @@ function validateEnv(env) {
46
50
  * cn-prod typo fails fast instead of dying inside the tunnel setup.
47
51
  */
48
52
  function validateEnvCnProd(env) {
49
- if (env !== 'stage' && env !== 'prod' && env !== 'cn-prod') {
50
- throw new Error(`--env must be "stage", "prod" or "cn-prod" (got: ${env})`);
53
+ if (env !== 'stage' && env !== 'prod' && env !== 'cn-prod' && env !== 'cn-stage') {
54
+ throw new Error(`--env must be "stage", "prod", "cn-prod" or "cn-stage" (got: ${env})`);
51
55
  }
52
56
  return env;
53
57
  }
@@ -86,6 +90,8 @@ const skillsUrlCache = {};
86
90
  function getBillingUrl(env) {
87
91
  if (env === 'cn-prod')
88
92
  return CN_PROD_BILLING_URL;
93
+ if (env === 'cn-stage')
94
+ return CN_STAGE_BILLING_URL;
89
95
  if (billingUrlCache[env])
90
96
  return billingUrlCache[env];
91
97
  const url = (0, infisical_secrets_1.fetchInfisicalSecret)(env, '/shared-secrets/domain-urls', 'BILLING_URL');
@@ -102,17 +108,34 @@ function getSkillsUrl(env) {
102
108
  function getServiceToken(env) {
103
109
  if (tokenCache[env])
104
110
  return tokenCache[env];
105
- const cfg = (0, db_utils_1.getInfisicalConfig)();
106
- const tok = (0, db_utils_1.getInfisicalToken)(cfg);
107
- // Fetch BOTH client_id and client_secret from Infisical they differ per env.
108
- // cn-prod credentials are mirrored in the AWS Infisical *prod* environment
109
- // (fetchInfisicalSecret has no cn-prod env slug), under CN-specific keys.
110
- const isCn = env === 'cn-prod';
111
- const infisicalEnv = isCn ? 'prod' : env;
112
- const idKey = isCn ? DEV_SKILLS_CN_CLIENT_ID_KEY : DEV_SKILLS_CLIENT_ID_KEY;
113
- const secretKey = isCn ? DEV_SKILLS_CN_CLIENT_SECRET_KEY : DEV_SKILLS_CLIENT_SECRET_KEY;
114
- const clientId = (0, infisical_secrets_1.fetchInfisicalSecret)(infisicalEnv, DEV_SKILLS_OAUTH_PATH, idKey, cfg, tok);
115
- const clientSecret = (0, infisical_secrets_1.fetchInfisicalSecret)(infisicalEnv, DEV_SKILLS_OAUTH_PATH, secretKey, cfg, tok);
111
+ // cn-prod cn-stage 都用 client_credentials M2M token,scope=internal:users:write。
112
+ // 凭证来源不同:
113
+ // · cn-prod —— 镜像在 AWS Infisical prod 环境(CN_PROD_ 前缀键),复用既有 AWS 访问。
114
+ // · cn-stage —— canonical cn Infisical staging /shared-secrets/oauth-clients
115
+ // (dev-skills 无该 client AWS 镜像;用 admin email/password 直读,
116
+ // 运行时需 INFISICAL_CN_EMAIL/PASSWORD,与 query-db cn 同一前提)。
117
+ const isCn = env === 'cn-prod' || env === 'cn-stage';
118
+ let clientId;
119
+ let clientSecret;
120
+ if (env === 'cn-stage') {
121
+ const cnTok = (0, db_utils_1.getCnInfisicalToken)();
122
+ const oc = (0, db_utils_1.getCnSecrets)(cnTok, '/shared-secrets/oauth-clients', false, 'staging');
123
+ clientId = oc['DEV_SKILLS_OAUTH_CLIENT_ID'];
124
+ clientSecret = oc['DEV_SKILLS_OAUTH_CLIENT_SECRET'];
125
+ if (!clientId || !clientSecret) {
126
+ throw new Error('cn-stage dev-skills OAuth client 凭证缺失(cn Infisical staging /shared-secrets/oauth-clients 的 DEV_SKILLS_OAUTH_CLIENT_ID/SECRET)。');
127
+ }
128
+ }
129
+ else {
130
+ const cfg = (0, db_utils_1.getInfisicalConfig)();
131
+ const tok = (0, db_utils_1.getInfisicalToken)(cfg);
132
+ // Fetch BOTH client_id and client_secret from Infisical — they differ per env.
133
+ const infisicalEnv = env === 'cn-prod' ? 'prod' : env;
134
+ const idKey = env === 'cn-prod' ? DEV_SKILLS_CN_CLIENT_ID_KEY : DEV_SKILLS_CLIENT_ID_KEY;
135
+ const secretKey = env === 'cn-prod' ? DEV_SKILLS_CN_CLIENT_SECRET_KEY : DEV_SKILLS_CLIENT_SECRET_KEY;
136
+ clientId = (0, infisical_secrets_1.fetchInfisicalSecret)(infisicalEnv, DEV_SKILLS_OAUTH_PATH, idKey, cfg, tok);
137
+ clientSecret = (0, infisical_secrets_1.fetchInfisicalSecret)(infisicalEnv, DEV_SKILLS_OAUTH_PATH, secretKey, cfg, tok);
138
+ }
116
139
  const authUrl = USER_AUTH_URLS[env];
117
140
  if (!authUrl)
118
141
  throw new Error(`Unknown env: ${env}`);
@@ -234,6 +257,24 @@ async function resolveUserIdByEmail(env, email) {
234
257
  console.log(`✓ Found user: ${parsed.user_id}`);
235
258
  return parsed.user_id;
236
259
  }
260
+ /**
261
+ * Resolve email → userId across all envs, picking the path that exists for
262
+ * each cloud. AWS (stage/prod) reaches the database over the RDS SSH tunnel
263
+ * (db-utils.resolveUserId). cn-prod / cn-stage have no tunnel into the Aliyun
264
+ * VPC-internal RDS, so they resolve via user-auth's internal HTTP lookup
265
+ * (resolveUserIdByEmail) — the cn dev-skills token carries the
266
+ * internal:users:write scope that endpoint requires. Lets the entitlement
267
+ * subcommands support cn the same way grant-subscription already does, without
268
+ * each call site re-implementing the branch.
269
+ */
270
+ async function resolveUserIdByEmailAnyEnv(env, email) {
271
+ if (env === 'cn-prod' || env === 'cn-stage') {
272
+ return resolveUserIdByEmail(env, email);
273
+ }
274
+ const cfg = (0, db_utils_1.getInfisicalConfig)();
275
+ const token = (0, db_utils_1.getInfisicalToken)(cfg);
276
+ return (0, db_utils_1.resolveUserId)(email, env, cfg, token);
277
+ }
237
278
  /**
238
279
  * Resolve a user's id by phone via user-auth's internal lookup endpoint
239
280
  * (POST /api/v1/internal/users/lookup with {phone}). Mirrors
@@ -36,13 +36,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.confirmIfProd = confirmIfProd;
37
37
  const readline = __importStar(require("readline"));
38
38
  /**
39
- * On prod, print the resolved action and require typing "yes" to proceed.
40
- * No-op on stage or when --yes was passed. Exits 1 if user declines.
39
+ * On a production env, print the resolved action and require typing "yes" to
40
+ * proceed. Both AWS `prod` and Aliyun `cn-prod` are production cn-prod must
41
+ * gate identically or the cn rollout silently loses the prod safety prompt.
42
+ * No-op on stage / cn-stage or when --yes was passed. Exits 1 if user declines.
41
43
  */
44
+ const PROD_ENVS = new Set(['prod', 'cn-prod']);
42
45
  async function confirmIfProd(env, actionDescription, skipFlag) {
43
- if (env !== 'prod' || skipFlag)
46
+ if (!PROD_ENVS.has(env) || skipFlag)
44
47
  return;
45
- console.log(`\n⚠️ About to perform on PROD:\n${actionDescription}\n`);
48
+ console.log(`\n⚠️ About to perform on ${env.toUpperCase()}:\n${actionDescription}\n`);
46
49
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
47
50
  const answer = await new Promise((resolve) => {
48
51
  rl.question('Type "yes" to confirm: ', (a) => { rl.close(); resolve(a.trim()); });
@@ -35,6 +35,9 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.RDS_HOSTS = void 0;
37
37
  exports.isCnEnv = isCnEnv;
38
+ exports.isCnStageEnv = isCnStageEnv;
39
+ exports.cnInfisicalEnv = cnInfisicalEnv;
40
+ exports.cnRdsHost = cnRdsHost;
38
41
  exports.escapeSQL = escapeSQL;
39
42
  exports.getGitHubVariable = getGitHubVariable;
40
43
  exports.getInfisicalConfig = getInfisicalConfig;
@@ -67,14 +70,29 @@ const EC2_HOST = '3.0.210.113';
67
70
  // ─── cn-prod (阿里云) 常量 ────────────────────────────────────────────────────
68
71
  // cn-prod 跑在阿里云 SAE,与海外 AWS 完全独立:独立 Infisical (secrets-cn.optima.chat)
69
72
  // + 内网 RDS(无公网端点)经 buildbox ECS 跳板 SSH 隧道访问。设计见 optima-dev-skills#21。
73
+ // cn Infisical 是【单实例双环境】:prod=cn-prod、staging=cn-stage(URL/org/project 共享,
74
+ // 只切 environment 参数)。cn-stage 数据面共用 prod VPC,故走【同一 buildbox】跳板。
70
75
  const CN_INFISICAL_URL = process.env.INFISICAL_CN_URL || 'https://secrets-cn.optima.chat';
71
76
  const CN_INFISICAL_ORG = process.env.INFISICAL_CN_ORG || 'f44012fa-0659-4f7e-b0ac-ed01244efc8a';
72
77
  const CN_INFISICAL_PROJECT = process.env.INFISICAL_CN_PROJECT || '4deef229-11be-40a5-8f56-b61f0bce7240';
73
- const CN_RDS_HOST = 'pgm-2zexwx9eso9e4yla.pg.rds.aliyuncs.com';
78
+ const CN_RDS_HOST = 'pgm-2zexwx9eso9e4yla.pg.rds.aliyuncs.com'; // cn-prod RDS
79
+ const CN_STAGE_RDS_HOST = 'pgm-2zem1u9zdh06boim.pg.rds.aliyuncs.com'; // cn-stage RDS(独立实例,共用 prod VPC)
74
80
  const CN_BUILDBOX_HOST = process.env.OPTIMA_CN_BUILDBOX_HOST || '47.94.105.163';
75
- /** env 是否指向 cn-prod(阿里云)。接受 `cn` / `cn-prod`。 */
81
+ /** env 是否指向阿里云(cn-prod 或 cn-stage)。接受 `cn` / `cn-prod` / `cn-stage`。 */
76
82
  function isCnEnv(env) {
77
- return env === 'cn' || env === 'cn-prod';
83
+ return env === 'cn' || env === 'cn-prod' || env === 'cn-stage';
84
+ }
85
+ /** env 是否指向 cn-stage(阿里云预发)。 */
86
+ function isCnStageEnv(env) {
87
+ return env === 'cn-stage';
88
+ }
89
+ /** cn env → cn Infisical environment slug。cn-stage→staging,其余 cn→prod。 */
90
+ function cnInfisicalEnv(env) {
91
+ return env === 'cn-stage' ? 'staging' : 'prod';
92
+ }
93
+ /** cn env → 内网 RDS host。cn-stage→stage 实例,其余 cn→prod 实例。 */
94
+ function cnRdsHost(env) {
95
+ return env === 'cn-stage' ? CN_STAGE_RDS_HOST : CN_RDS_HOST;
78
96
  }
79
97
  // ─── SQL escaping ───────────────────────────────────────────────────────────
80
98
  /** Escape a string value for safe inclusion in SQL single-quoted literals. */
@@ -146,10 +164,13 @@ function getCnInfisicalToken() {
146
164
  throw new Error(`cn Infisical select-organization 失败: ${JSON.stringify(org).slice(0, 200)}`);
147
165
  return org.token;
148
166
  }
149
- /** 读 cn Infisical 某 folder(prod env)→ key→value map。expand=true 让服务端解析 `${...}` 引用。 */
150
- function getCnSecrets(token, secretPath, expand = false) {
167
+ /**
168
+ * cn Infisical 某 folder → key→value map。expand=true 让服务端解析 `${...}` 引用。
169
+ * environment:cn-prod 用 `prod`、cn-stage 用 `staging`(同一实例双环境)。
170
+ */
171
+ function getCnSecrets(token, secretPath, expand = false, environment = 'prod') {
151
172
  const data = curlJson([
152
- `${CN_INFISICAL_URL}/api/v3/secrets/raw?workspaceId=${CN_INFISICAL_PROJECT}&environment=prod&secretPath=${encodeURIComponent(secretPath)}&expandSecretReferences=${expand}`,
173
+ `${CN_INFISICAL_URL}/api/v3/secrets/raw?workspaceId=${CN_INFISICAL_PROJECT}&environment=${environment}&secretPath=${encodeURIComponent(secretPath)}&expandSecretReferences=${expand}`,
153
174
  '-H', `Authorization: Bearer ${token}`,
154
175
  ]);
155
176
  const secrets = {};
@@ -460,17 +481,18 @@ function queryDB(conn, sql) {
460
481
  * 连 cn-prod 某服务库:creds 取自 cn Infisical 的 /shared-secrets/database-users +
461
482
  * /database-names(按 prefix,如 AUTH/BILLING),经 buildbox 跳板隧道(动态端口)。
462
483
  */
463
- function connectCnDB(prefix) {
484
+ function connectCnDB(prefix, cnEnv = 'cn-prod') {
485
+ const infEnv = cnInfisicalEnv(cnEnv);
464
486
  const token = getCnInfisicalToken();
465
- const users = getCnSecrets(token, '/shared-secrets/database-users');
466
- const names = getCnSecrets(token, '/shared-secrets/database-names');
487
+ const users = getCnSecrets(token, '/shared-secrets/database-users', false, infEnv);
488
+ const names = getCnSecrets(token, '/shared-secrets/database-names', false, infEnv);
467
489
  const user = users[`${prefix}_DB_USER`];
468
490
  const password = users[`${prefix}_DB_PASSWORD`];
469
491
  const database = names[`${prefix}_DB_NAME`];
470
492
  if (!user || !password || !database) {
471
493
  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。`);
472
494
  }
473
- const port = ensureTunnel(CN_RDS_HOST, 'cn-buildbox');
495
+ const port = ensureTunnel(cnRdsHost(cnEnv), 'cn-buildbox');
474
496
  const conn = { host: 'localhost', port, user, password, database };
475
497
  return { query: (sql) => queryDB(conn, sql) };
476
498
  }
@@ -479,9 +501,9 @@ function connectCnDB(prefix) {
479
501
  * 用于 cred 不在 shared-secrets/database-users 的服务(如 gateway-core)。
480
502
  * expandSecretReferences=true 让 cn Infisical 解析 `${...}` 引用为字面值。
481
503
  */
482
- function connectCnDBFromUrl(servicePath) {
504
+ function connectCnDBFromUrl(servicePath, cnEnv = 'cn-prod') {
483
505
  const token = getCnInfisicalToken();
484
- const secrets = getCnSecrets(token, servicePath, true);
506
+ const secrets = getCnSecrets(token, servicePath, true, cnInfisicalEnv(cnEnv));
485
507
  const dbUrl = secrets['DATABASE_URL'];
486
508
  if (!dbUrl)
487
509
  throw new Error(`cn DATABASE_URL 未找到 at ${servicePath}`);
@@ -24,7 +24,7 @@ Optional:
24
24
  --ends <date> Valid-until
25
25
  --max <N> Max total redemptions (default: unlimited; 1 = single-use)
26
26
  --campaign <label> Grouping label
27
- --env stage|prod (default: stage)
27
+ --env stage|prod|cn-prod|cn-stage (default: stage)
28
28
  --yes Skip prod confirmation`);
29
29
  process.exit(0);
30
30
  }
@@ -81,7 +81,7 @@ Optional:
81
81
  }
82
82
  async function runCreate(argv) {
83
83
  const args = parseArgs(argv);
84
- (0, billing_http_1.validateEnv)(args.env);
84
+ (0, billing_http_1.validateEnvCnProd)(args.env);
85
85
  await (0, confirm_prompt_1.confirmIfProd)(args.env, `Create discount code ${args.code.toUpperCase()} (${args.percentOff}% off)`, args.yes);
86
86
  const body = { code: args.code, percentOff: args.percentOff };
87
87
  if (args.productKeys)
@@ -11,7 +11,7 @@ Required:
11
11
  --code <CODE>
12
12
 
13
13
  Optional:
14
- --env stage|prod (default: stage)
14
+ --env stage|prod|cn-prod|cn-stage (default: stage)
15
15
  --yes Skip prod confirmation`);
16
16
  process.exit(0);
17
17
  }
@@ -40,7 +40,7 @@ Optional:
40
40
  }
41
41
  async function runDisable(argv) {
42
42
  const args = parseArgs(argv);
43
- (0, billing_http_1.validateEnv)(args.env);
43
+ (0, billing_http_1.validateEnvCnProd)(args.env);
44
44
  await (0, confirm_prompt_1.confirmIfProd)(args.env, `Disable discount code ${args.code.toUpperCase()}`, args.yes);
45
45
  const res = await (0, billing_http_1.callBilling)(args.env, 'PATCH', `/api/billing/admin/discount-codes/${encodeURIComponent(args.code)}`, { status: 'DISABLED' });
46
46
  console.log(`✓ Disabled (HTTP ${res.status}):`);
@@ -59,7 +59,7 @@ Optional:
59
59
  --products <a,b,...> Limit to these productKeys
60
60
  --starts <date> Valid-from (YYYY-MM-DD or ISO)
61
61
  --ends <date> Valid-until
62
- --env stage|prod (default: stage)
62
+ --env stage|prod|cn-prod|cn-stage (default: stage)
63
63
  --yes Skip prod confirmation`);
64
64
  process.exit(0);
65
65
  }
@@ -114,7 +114,7 @@ Optional:
114
114
  }
115
115
  async function runGenerate(argv) {
116
116
  const args = parseArgs(argv);
117
- (0, billing_http_1.validateEnv)(args.env);
117
+ (0, billing_http_1.validateEnvCnProd)(args.env);
118
118
  await (0, confirm_prompt_1.confirmIfProd)(args.env, `Generate ${args.count} unique discount codes (${args.percentOff}% off, campaign ${args.campaign})`, args.yes);
119
119
  const body = { count: args.count, percentOff: args.percentOff, campaign: args.campaign };
120
120
  if (args.productKeys)
@@ -10,7 +10,7 @@ Optional:
10
10
  --campaign <label> Filter by campaign
11
11
  --code <CODE> Filter by exact code
12
12
  --limit <N> Max rows (default 500, max 1000)
13
- --env stage|prod (default: stage)`);
13
+ --env stage|prod|cn-prod|cn-stage (default: stage)`);
14
14
  process.exit(0);
15
15
  }
16
16
  const out = { env: 'stage' };
@@ -45,7 +45,7 @@ Optional:
45
45
  }
46
46
  async function runList(argv) {
47
47
  const args = parseArgs(argv);
48
- (0, billing_http_1.validateEnv)(args.env);
48
+ (0, billing_http_1.validateEnvCnProd)(args.env);
49
49
  const qs = new URLSearchParams();
50
50
  if (args.campaign)
51
51
  qs.set('campaign', args.campaign);
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runGrant = runGrant;
4
4
  const billing_http_1 = require("../billing-http");
5
5
  const confirm_prompt_1 = require("../confirm-prompt");
6
- const db_utils_1 = require("../db-utils");
7
6
  function parseArgs(argv) {
8
7
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
9
8
  console.log(`Usage: optima-entitlement grant --email <user> --product-key <slug> --justification "..." [options]
@@ -15,7 +14,7 @@ Required:
15
14
 
16
15
  Optional:
17
16
  --yes Skip prod confirmation prompt (no-op on stage)
18
- --env stage|prod (default: stage)
17
+ --env stage|prod|cn-prod|cn-stage (default: stage)
19
18
 
20
19
  Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
21
20
  process.exit(0);
@@ -57,10 +56,8 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
57
56
  }
58
57
  async function runGrant(argv) {
59
58
  const args = parseArgs(argv);
60
- (0, billing_http_1.validateEnv)(args.env);
61
- const cfg = (0, db_utils_1.getInfisicalConfig)();
62
- const token = (0, db_utils_1.getInfisicalToken)(cfg);
63
- const userId = await (0, db_utils_1.resolveUserId)(args.email, args.env, cfg, token);
59
+ (0, billing_http_1.validateEnvCnProd)(args.env);
60
+ const userId = await (0, billing_http_1.resolveUserIdByEmailAnyEnv)(args.env, args.email);
64
61
  await (0, confirm_prompt_1.confirmIfProd)(args.env, `Action: GRANT product '${args.productKey}' to user ${args.email} (userId=${userId}) on ${args.env.toUpperCase()}\nJustification: ${args.justification}`, args.yes);
65
62
  console.log(`\n🎁 Granting ${args.productKey} to ${args.email}...`);
66
63
  const res = await (0, billing_http_1.callBilling)(args.env, 'POST', '/api/billing/admin/grant-entitlement', {
@@ -2,7 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runList = runList;
4
4
  const billing_http_1 = require("../billing-http");
5
- const db_utils_1 = require("../db-utils");
6
5
  function parseArgs(argv) {
7
6
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
8
7
  console.log(`Usage: optima-entitlement list --email <user-email> [options]
@@ -11,7 +10,7 @@ Required:
11
10
  --email <user-email> Resolved to userId via user-auth DB
12
11
 
13
12
  Optional:
14
- --env stage|prod (default: stage)`);
13
+ --env stage|prod|cn-prod|cn-stage (default: stage)`);
15
14
  process.exit(0);
16
15
  }
17
16
  const out = { env: 'stage' };
@@ -36,10 +35,8 @@ Optional:
36
35
  }
37
36
  async function runList(argv) {
38
37
  const args = parseArgs(argv);
39
- (0, billing_http_1.validateEnv)(args.env);
40
- const cfg = (0, db_utils_1.getInfisicalConfig)();
41
- const token = (0, db_utils_1.getInfisicalToken)(cfg);
42
- const userId = await (0, db_utils_1.resolveUserId)(args.email, args.env, cfg, token);
38
+ (0, billing_http_1.validateEnvCnProd)(args.env);
39
+ const userId = await (0, billing_http_1.resolveUserIdByEmailAnyEnv)(args.env, args.email);
43
40
  const res = await (0, billing_http_1.callBilling)(args.env, 'GET', `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`);
44
41
  const rows = res.body.entitlements ?? [];
45
42
  if (rows.length === 0) {
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runRevoke = runRevoke;
4
4
  const billing_http_1 = require("../billing-http");
5
5
  const confirm_prompt_1 = require("../confirm-prompt");
6
- const db_utils_1 = require("../db-utils");
7
6
  function parseArgs(argv) {
8
7
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
9
8
  console.log(`Usage: optima-entitlement revoke --email <user> --product-key <slug> --reason "..." [options]
@@ -15,7 +14,7 @@ Required:
15
14
 
16
15
  Optional:
17
16
  --yes Skip prod confirmation prompt (no-op on stage)
18
- --env stage|prod (default: stage)
17
+ --env stage|prod|cn-prod|cn-stage (default: stage)
19
18
 
20
19
  Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
21
20
  error pointing to the right reversal flow.`);
@@ -60,10 +59,8 @@ const PAYMENT_REFUSAL = `refusing to revoke a PAYMENT-source entitlement via CLI
60
59
  const PARTNER_REFUSAL = `refusing to revoke a PARTNER-source entitlement via CLI; PARTNER grants are issued out-of-band and must be reversed via the partner contract / process that issued them. Manual psql is the escape hatch if absolutely necessary.`;
61
60
  async function runRevoke(argv) {
62
61
  const args = parseArgs(argv);
63
- (0, billing_http_1.validateEnv)(args.env);
64
- const cfg = (0, db_utils_1.getInfisicalConfig)();
65
- const token = (0, db_utils_1.getInfisicalToken)(cfg);
66
- const userId = await (0, db_utils_1.resolveUserId)(args.email, args.env, cfg, token);
62
+ (0, billing_http_1.validateEnvCnProd)(args.env);
63
+ const userId = await (0, billing_http_1.resolveUserIdByEmailAnyEnv)(args.env, args.email);
67
64
  // Step 1: Fetch user's entitlements
68
65
  const listRes = await (0, billing_http_1.callBilling)(args.env, 'GET', `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`);
69
66
  const all = listRes.body.entitlements ?? [];