@optima-chat/dev-skills 0.8.1 → 0.9.0

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.
@@ -3,18 +3,19 @@ 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 grant_subscription_1 = require("../grant-subscription");
6
7
  function parseArgs(argv) {
7
8
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
8
- console.log(`Usage: optima-entitlement revoke --email <user> --product-key <slug> --reason "..." [options]
9
+ console.log(`Usage: optima-entitlement revoke <email|phone|userId> --product-key <slug> --reason "..." [options]
9
10
 
10
11
  Required:
11
- --email <user-email> Resolved to userId via user-auth DB
12
+ <email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
12
13
  --product-key <productKey>
13
14
  --reason "..." Required by billing (400 otherwise); stored on entitlement.refundReason
14
15
 
15
16
  Optional:
16
- --yes Skip prod confirmation prompt (no-op on stage)
17
- --env stage|prod|cn-prod|cn-stage (default: stage)
17
+ --yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
18
+ --env stage|prod|cn-prod|cn-stage (default: stage)
18
19
 
19
20
  Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
20
21
  error pointing to the right reversal flow.`);
@@ -26,9 +27,9 @@ error pointing to the right reversal flow.`);
26
27
  const next = argv[i + 1];
27
28
  switch (a) {
28
29
  case '--email':
29
- out.email = next;
30
+ out.identifier = next;
30
31
  i++;
31
- break;
32
+ break; // back-compat alias for the positional identifier
32
33
  case '--product-key':
33
34
  out.productKey = next;
34
35
  i++;
@@ -44,11 +45,16 @@ error pointing to the right reversal flow.`);
44
45
  out.env = next;
45
46
  i++;
46
47
  break;
47
- default: throw new Error(`Unknown arg: ${a}`);
48
+ default:
49
+ if (a.startsWith('--'))
50
+ throw new Error(`Unknown arg: ${a}`);
51
+ if (out.identifier)
52
+ throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
53
+ out.identifier = a;
48
54
  }
49
55
  }
50
- if (!out.email)
51
- throw new Error('--email required');
56
+ if (!out.identifier)
57
+ throw new Error('target user required (<email|phone|userId> positional, or --email)');
52
58
  if (!out.productKey)
53
59
  throw new Error('--product-key required');
54
60
  if (!out.reason)
@@ -60,7 +66,7 @@ const PARTNER_REFUSAL = `refusing to revoke a PARTNER-source entitlement via CLI
60
66
  async function runRevoke(argv) {
61
67
  const args = parseArgs(argv);
62
68
  (0, billing_http_1.validateEnvCnProd)(args.env);
63
- const userId = await (0, billing_http_1.resolveUserIdByEmailAnyEnv)(args.env, args.email);
69
+ const { userId } = await (0, grant_subscription_1.resolveTargetUser)(args.env, args.identifier);
64
70
  // Step 1: Fetch user's entitlements
65
71
  const listRes = await (0, billing_http_1.callBilling)(args.env, 'GET', `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`);
66
72
  const all = listRes.body.entitlements ?? [];
@@ -68,10 +74,10 @@ async function runRevoke(argv) {
68
74
  const matches = all.filter((e) => e.status === 'ACTIVE' && e.productKey === args.productKey);
69
75
  // Step 3: Validate count (partial unique index enforces ≤1 ACTIVE)
70
76
  if (matches.length === 0) {
71
- throw new Error(`no active entitlement for (user=${args.email}, product=${args.productKey}) on ${args.env}`);
77
+ throw new Error(`no active entitlement for (user=${args.identifier}, product=${args.productKey}) on ${args.env}`);
72
78
  }
73
79
  if (matches.length > 1) {
74
- throw new Error(`unexpected: ${matches.length} ACTIVE entitlements for (user, product) — partial unique constraint should prevent this. Inspect with: optima-entitlement list --email ${args.email}`);
80
+ throw new Error(`unexpected: ${matches.length} ACTIVE entitlements for (user, product) — partial unique constraint should prevent this. Inspect with: optima-entitlement list ${args.identifier}`);
75
81
  }
76
82
  const target = matches[0];
77
83
  // Step 4: Validate source
@@ -81,7 +87,7 @@ async function runRevoke(argv) {
81
87
  throw new Error(PARTNER_REFUSAL);
82
88
  if (target.source !== 'ADMIN_GRANT')
83
89
  throw new Error(`unknown entitlement source: ${target.source}`);
84
- await (0, confirm_prompt_1.confirmIfProd)(args.env, `Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for user ${args.email} (userId=${userId}) on ${args.env.toUpperCase()}\nReason: ${args.reason}`, args.yes);
90
+ await (0, confirm_prompt_1.confirmIfProd)(args.env, `Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for ${args.identifier} (userId=${userId}) on ${args.env.toUpperCase()}\nReason: ${args.reason}`, args.yes);
85
91
  // Step 5: Refund
86
92
  // refundAmountCents=0 is always correct for ADMIN_GRANT (priceCents=0,
87
93
  // no upstream charge). Pass explicitly so billing's auto-compute
@@ -91,7 +97,7 @@ async function runRevoke(argv) {
91
97
  // this branch only ever runs for ADMIN_GRANT (priceCents=0) — Stripe
92
98
  // refund path in billing (admin-products.ts:296-307) is gated on
93
99
  // source=PAYMENT and won't trigger here.
94
- console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.email}...`);
100
+ console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.identifier}...`);
95
101
  const res = await (0, billing_http_1.callBilling)(args.env, 'POST', '/api/billing/admin/refund-entitlement', {
96
102
  entitlementId: target.id,
97
103
  refundReason: args.reason,
@@ -6,16 +6,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  // 改调 billing 服务态端点(grantCredits → bonus 积分)。
7
7
  // ⚠️ 语义变化:旧 wallet granted 无期限;积分 bonus 桶标准 30 天有效期。
8
8
  const crypto_1 = require("crypto");
9
- const db_utils_1 = require("./db-utils");
10
9
  const billing_http_1 = require("./billing-http");
10
+ const grant_subscription_1 = require("./grant-subscription");
11
11
  function parseArgs(args) {
12
12
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
13
- console.log(`Usage: optima-grant-balance <email> --amount <usd> [options]
13
+ console.log(`Usage: optima-grant-balance <email|phone|userId> --amount <usd> [options]
14
14
 
15
15
  Grant credits to a user (bonus bucket, expires in 30 days).
16
16
  Used for promotional grants, compensation, referral rewards, etc.
17
17
  $1 = 700 credits (P15 unified ledger; the USD wallet is retired).
18
18
 
19
+ Target user: <email|phone|userId> (positional). phone/userId only on
20
+ cn-prod / cn-stage; AWS stage/prod resolve email only.
21
+
19
22
  Options:
20
23
  --amount <usd> USD amount to grant (required, e.g. 5 for $5.00 = 3500 credits)
21
24
  --description <text> Description for audit trail (optional)
@@ -24,12 +27,11 @@ Options:
24
27
 
25
28
  Examples:
26
29
  optima-grant-balance user@example.com --amount 5 --env prod
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)
30
+ optima-grant-balance 18898654855 --amount 1 --env cn-prod # 手机号(cn 用户多为手机号注册)
29
31
  optima-grant-balance user@example.com --amount 1 --env cn-stage # 阿里云预发`);
30
32
  process.exit(0);
31
33
  }
32
- const email = args[0];
34
+ const identifier = args[0];
33
35
  let amountUsd = 0;
34
36
  let description = null;
35
37
  let env = 'stage';
@@ -49,24 +51,17 @@ Examples:
49
51
  process.exit(1);
50
52
  }
51
53
  (0, billing_http_1.validateEnvCnProd)(env);
52
- return { email, amountUsd, description, env };
54
+ return { identifier, amountUsd, description, env };
53
55
  }
54
56
  async function main() {
55
- const { email, amountUsd, description, env } = parseArgs(process.argv.slice(2));
56
- console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${email} [${env.toUpperCase()}]\n`);
57
+ const { identifier, amountUsd, description, env } = parseArgs(process.argv.slice(2));
58
+ console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${identifier} [${env.toUpperCase()}]\n`);
57
59
  if (description)
58
60
  console.log(` Reason: ${description}`);
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.
61
- let userId;
62
- if (env === 'cn-prod' || env === 'cn-stage') {
63
- userId = await (0, billing_http_1.resolveUserIdByEmail)(env, email);
64
- }
65
- else {
66
- const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
67
- const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
68
- userId = await (0, db_utils_1.resolveUserId)(email, env, infisicalConfig, token);
69
- }
61
+ // Shared resolver: classify→resolve→reverse-verify echo→phone-assert on cn
62
+ // (so phone/userId works for cn's phone-registered users, gateway#923);
63
+ // email-only via the RDS SSH tunnel on AWS.
64
+ const { userId } = await (0, grant_subscription_1.resolveTargetUser)(env, identifier);
70
65
  // 幂等键 per-invocation 生成、callBilling 的 5xx retry 复用同 body —— 「已
71
66
  // commit 但响应 5xx」场景重试不双发(billing spec R2-M3)。
72
67
  const { body } = await (0, billing_http_1.callBilling)(env, 'POST', '/api/billing/admin/grant-credits', {
@@ -76,7 +71,7 @@ async function main() {
76
71
  idempotencyKey: `dev-skills-grant:${(0, crypto_1.randomUUID)()}`,
77
72
  });
78
73
  console.log(`✓ Granted ${body.credits} credits (lot ${body.lotId})`);
79
- console.log(`\n✅ Done! ${email} received ${body.credits} bonus credits (expires in 30 days)\n`);
74
+ console.log(`\n✅ Done! ${identifier} received ${body.credits} bonus credits (expires in 30 days)\n`);
80
75
  }
81
76
  main().catch(error => {
82
77
  console.error('\n❌ Error:', error.message);
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.classifyIdentifier = classifyIdentifier;
5
5
  exports.assertPhoneMatch = assertPhoneMatch;
6
6
  exports.assertAwsEmailOnly = assertAwsEmailOnly;
7
+ exports.resolveTargetUser = resolveTargetUser;
7
8
  // P15 D8b:USD 钱包退役——原「SSH 直写 subscriptions/usd_wallets/token_quotas」
8
9
  // 作废,改调 billing 服务态端点(与用户态 /api/admin/grant-subscription 同
9
10
  // 业务体:supersede 旧授予 + 实得 credits + token quota,重试双发不双倍)。
@@ -108,19 +109,29 @@ Options:
108
109
  }
109
110
  return { identifier, plan, months, env };
110
111
  }
111
- async function main() {
112
- const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
112
+ /**
113
+ * Resolve a CLI identifier (`<email|phone|userId>`) to a userId + verified
114
+ * account identity, handling the AWS-vs-cn split. Shared by grant-subscription,
115
+ * grant-balance, the optima-entitlement subcommands, and optima-account so all
116
+ * four accept the same identifier forms (gateway#923: phone/userId on cn).
117
+ *
118
+ * - AWS (stage/prod): email-only via the RDS SSH tunnel; no internal HTTP
119
+ * reverse-verify (the AWS dev-skills token lacks internal:users:write).
120
+ * - cn-prod / cn-stage: no tunnel into the Aliyun RDS — classify → resolve via
121
+ * user-auth internal endpoints → getUserById reverse-verify → phone-assert.
122
+ * cn-stage shares cn-prod's HTTP path (same internal endpoints, cn-stage M2M
123
+ * token); the split is AWS-vs-cn, NOT prod-vs-stage.
124
+ *
125
+ * Prints a `🎯 目标账号` line in BOTH branches before returning, so a wrong
126
+ * userId is caught by eye before any destructive call (ban/grant/revoke).
127
+ */
128
+ async function resolveTargetUser(env, identifier) {
113
129
  const kind = classifyIdentifier(identifier);
114
130
  assertAwsEmailOnly(env, kind);
115
- console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${kind}) for ${months} month(s) [${env.toUpperCase()}]\n`);
116
- // identity is the verified target account to print on success. AWS keeps the
117
- // pre-change behavior (email-only, no internal HTTP reverse-verify); only
118
- // cn-prod / cn-stage run the full classify→resolve→getUserById→phone-assert防呆 path.
119
- let userId;
120
- let identity;
121
131
  if (env === 'cn-prod' || env === 'cn-stage') {
122
132
  // cn-prod / cn-stage have no SSH tunnel into the Aliyun RDS — resolve via
123
133
  // user-auth, and the dev-skills token carries internal:users:write for lookups.
134
+ let userId;
124
135
  if (kind === 'userId') {
125
136
  userId = identifier;
126
137
  }
@@ -131,25 +142,31 @@ async function main() {
131
142
  userId = await (0, billing_http_1.resolveUserIdByEmail)(env, identifier);
132
143
  }
133
144
  // Reverse-verify: fetch and loudly print the target account identity
134
- // before granting, so a wrong userId is caught by eye (gateway#923).
145
+ // before any mutation, so a wrong userId is caught by eye (gateway#923).
135
146
  const acct = await (0, billing_http_1.getUserById)(env, userId);
136
147
  console.log(`🎯 目标账号: userId=${userId} 手机=${acct.phone || '(无)'} email=${acct.email || '(无)'} 当前plan=${acct.current_plan || '?'}`);
137
148
  // Hard assertion: a phone-input grant must land on an account whose phone
138
- // matches. Runs BEFORE callBilling — a mismatch aborts without granting.
149
+ // matches. Runs BEFORE the caller's mutation — a mismatch aborts.
139
150
  if (kind === 'phone') {
140
151
  assertPhoneMatch(identifier, acct.phone);
141
152
  }
142
- identity = { phone: acct.phone, email: acct.email };
143
- }
144
- else {
145
- // AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No
146
- // internal HTTP reverse-verify (token lacks internal:users:write → 403).
147
- const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
148
- const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
149
- userId = await (0, db_utils_1.resolveUserId)(identifier, env, infisicalConfig, token);
150
- console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
151
- identity = { phone: null, email: identifier };
153
+ return { userId, kind, identity: { phone: acct.phone, email: acct.email } };
152
154
  }
155
+ // AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No internal
156
+ // HTTP reverse-verify (token lacks internal:users:write → 403), but still
157
+ // echo the resolved account so destructive ops have a confirmation line (R1).
158
+ const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
159
+ const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
160
+ const userId = await (0, db_utils_1.resolveUserId)(identifier, env, infisicalConfig, token);
161
+ console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
162
+ return { userId, kind, identity: { phone: null, email: identifier } };
163
+ }
164
+ async function main() {
165
+ const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
166
+ console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${classifyIdentifier(identifier)}) for ${months} month(s) [${env.toUpperCase()}]\n`);
167
+ // Shared resolver: classify → resolve → reverse-verify echo → phone-assert
168
+ // (cn-prod/cn-stage), or email-only via SSH tunnel (AWS). See resolveTargetUser.
169
+ const { userId, identity } = await resolveTargetUser(env, identifier);
153
170
  const { body } = await (0, billing_http_1.callBilling)(env, 'POST', '/api/billing/admin/grant-subscription', { userId, planId: plan, months });
154
171
  console.log(`✓ Subscription ${body.subscriptionId} (${body.planId})`);
155
172
  console.log(`✓ Credits: ${body.credits.toLocaleString()} (expires ${body.expiresAt})`);
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ /**
5
+ * optima-logs —— 一条命令直取服务日志,四环境统一,用 --env 区分。
6
+ *
7
+ * stage / prod : AWS CloudWatch(`aws logs tail /ecs/<svc>-<env>`)
8
+ * cn-prod / cn-stage: 阿里云 SLS 直连(`aliyun sls GetLogs`)
9
+ *
10
+ * cn 的关键改进:旧流程要 SSH 进 buildbox 再调 SAE `DescribeInstanceLog`,
11
+ * 只能看实例**当前缓冲**(重启即丢、不能检索)。现在 cn-prod/cn-stage 全部
12
+ * 服务已接 SLS,GetLogs 是公网控制面 API,本机 `aliyun-optima` profile 直连即可:
13
+ * - 免 buildbox 跳板
14
+ * - 支持时间窗(--since)+ 关键词检索(--grep)+ 历史(重启不丢)
15
+ *
16
+ * 前置:
17
+ * AWS → 已配 aws CLI 凭证(ap-southeast-1)
18
+ * cn → 已配 aliyun CLI profile `aliyun-optima`(cn-beijing)
19
+ *
20
+ * 用法:
21
+ * optima-logs gateway-core # 默认 cn-prod,最近 1h,100 行
22
+ * optima-logs gateway-core --env cn-stage
23
+ * optima-logs user-auth --env prod --since 2h
24
+ * optima-logs commerce-backend --grep error -n 200
25
+ * optima-logs gateway-core --since 30m --json # 机器可读
26
+ *
27
+ * service == SLS logstore == AWS 日志组 `/ecs/<svc>-<env>` 的 <svc>(同名)。
28
+ */
29
+ const child_process_1 = require("child_process");
30
+ const ALIYUN_ACCOUNT = '1911493506120573';
31
+ const ALIYUN_PROFILE = 'aliyun-optima';
32
+ const ALIYUN_REGION = 'cn-beijing';
33
+ const AWS_REGION = 'ap-southeast-1';
34
+ const AWS_ENVS = ['stage', 'prod'];
35
+ const CN_ENVS = ['cn-prod', 'cn-stage'];
36
+ const ALL_ENVS = [...AWS_ENVS, ...CN_ENVS];
37
+ const C = { g: '\x1b[32m', y: '\x1b[33m', b: '\x1b[34m', d: '\x1b[2m', n: '\x1b[0m' };
38
+ const HELP = `Usage: optima-logs <service> [options]
39
+
40
+ 四环境统一查日志,cn 直连阿里云 SLS(免 buildbox),AWS 走 CloudWatch。
41
+
42
+ Required:
43
+ <service> 服务名(== SLS logstore == /ecs/<svc>-<env> 的 <svc>)
44
+
45
+ Optional:
46
+ --env <e> stage | prod | cn-prod | cn-stage (default: cn-prod)
47
+ --lines, -n <N> 返回行数 (default: 100)
48
+ --since <dur> 时间窗,如 30m / 2h / 1d / 3600(秒) (default: 1h)
49
+ --grep <kw> 关键词检索(cn=SLS query / aws=filter-pattern)
50
+ --json 原始 JSON 输出(接管道)
51
+ --help, -h 显示本帮助
52
+
53
+ Examples:
54
+ optima-logs gateway-core
55
+ optima-logs user-auth --env prod --since 2h
56
+ optima-logs commerce-backend --env cn-prod --grep error -n 200`;
57
+ /** 把 30m / 2h / 1d / 纯秒 解析成秒数;同时回填给 aws 用的带单位字符串。 */
58
+ function parseSince(raw) {
59
+ const m = raw.match(/^(\d+)([smhdw]?)$/);
60
+ if (!m)
61
+ throw new Error(`--since 格式非法: ${raw}(用 30m / 2h / 1d / 纯秒)`);
62
+ const n = parseInt(m[1], 10);
63
+ const unit = m[2] || 's';
64
+ const mult = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
65
+ return { sec: n * mult[unit], awsStr: `${n}${unit}` };
66
+ }
67
+ function parseArgs(argv) {
68
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
69
+ console.log(HELP);
70
+ process.exit(0);
71
+ }
72
+ const out = { env: 'cn-prod', lines: 100, since: '1h', json: false };
73
+ const positional = [];
74
+ for (let i = 0; i < argv.length; i++) {
75
+ const a = argv[i];
76
+ const next = argv[i + 1];
77
+ switch (a) {
78
+ case '--env':
79
+ out.env = next;
80
+ i++;
81
+ break;
82
+ case '--lines':
83
+ case '-n': {
84
+ const v = parseInt(next, 10);
85
+ if (isNaN(v) || v <= 0)
86
+ throw new Error('--lines 需要正整数');
87
+ out.lines = v;
88
+ i++;
89
+ break;
90
+ }
91
+ case '--since':
92
+ out.since = next;
93
+ i++;
94
+ break;
95
+ case '--grep':
96
+ out.grep = next;
97
+ i++;
98
+ break;
99
+ case '--json':
100
+ out.json = true;
101
+ break;
102
+ default:
103
+ if (a.startsWith('-'))
104
+ throw new Error(`未知参数: ${a}`);
105
+ positional.push(a);
106
+ }
107
+ }
108
+ if (positional.length === 0)
109
+ throw new Error('缺少 <service>(见 --help)');
110
+ if (positional.length > 1)
111
+ throw new Error(`多余参数: ${positional.slice(1).join(' ')}`);
112
+ out.service = positional[0];
113
+ if (!ALL_ENVS.includes(out.env)) {
114
+ throw new Error(`--env 非法: ${out.env}(可选 ${ALL_ENVS.join(' | ')})`);
115
+ }
116
+ return out;
117
+ }
118
+ function run(cmd, args) {
119
+ return (0, child_process_1.execFileSync)(cmd, args, { encoding: 'utf-8', maxBuffer: 128 * 1024 * 1024 });
120
+ }
121
+ /** AWS CloudWatch:aws logs tail /ecs/<svc>-<env>。 */
122
+ function fetchAws(args) {
123
+ const group = `/ecs/${args.service}-${args.env}`;
124
+ const { awsStr } = parseSince(args.since);
125
+ const cmd = ['logs', 'tail', group, '--since', awsStr, '--region', AWS_REGION, '--format', args.json ? 'json' : 'short'];
126
+ if (args.grep)
127
+ cmd.push('--filter-pattern', args.grep);
128
+ process.stderr.write(`${C.d}# AWS CloudWatch ${group} (since ${awsStr}${args.grep ? `, grep "${args.grep}"` : ''})${C.n}\n`);
129
+ try {
130
+ process.stdout.write(run('aws', cmd));
131
+ }
132
+ catch (e) {
133
+ if (/ResourceNotFoundException/.test(e.stderr || e.message || '')) {
134
+ throw new Error(`日志组不存在: ${group}\n 确认服务名/环境对,或该服务未部署在 ${args.env}。`);
135
+ }
136
+ throw new Error(e.stderr || e.message);
137
+ }
138
+ }
139
+ /** 阿里云 SLS:aliyun sls GetLogs,project=optima-<env>-<account>,logstore=service。 */
140
+ function fetchCn(args) {
141
+ const project = `optima-${args.env}-${ALIYUN_ACCOUNT}`;
142
+ const { sec } = parseSince(args.since);
143
+ const now = Math.floor(Date.now() / 1000);
144
+ const from = now - sec;
145
+ const cmd = [
146
+ 'sls', 'GetLogs',
147
+ '--project', project,
148
+ '--logstore', args.service,
149
+ '--from', String(from),
150
+ '--to', String(now),
151
+ '--line', String(args.lines),
152
+ '--reverse', 'true', // 先拿最新 N 条
153
+ '--region', ALIYUN_REGION,
154
+ '--profile', ALIYUN_PROFILE,
155
+ ];
156
+ if (args.grep) {
157
+ cmd.push('--query', args.grep);
158
+ }
159
+ process.stderr.write(`${C.d}# 阿里云 SLS ${project}/${args.service} (since ${args.since}${args.grep ? `, query "${args.grep}"` : ''})${C.n}\n`);
160
+ let raw;
161
+ try {
162
+ raw = run('aliyun', cmd);
163
+ }
164
+ catch (e) {
165
+ const msg = e.stderr || e.message || '';
166
+ if (/LogStoreNotExist|ProjectNotExist/.test(msg)) {
167
+ throw new Error(`SLS logstore 不存在: ${project}/${args.service}\n 确认服务名对,或该服务未接 SLS。列全部:\n aliyun sls ListLogStores --project ${project} --region ${ALIYUN_REGION} --profile ${ALIYUN_PROFILE}`);
168
+ }
169
+ throw new Error(msg);
170
+ }
171
+ const rows = JSON.parse(raw || '[]');
172
+ // reverse=true 取到的是新→旧,翻回旧→新便于阅读
173
+ rows.reverse();
174
+ if (args.json) {
175
+ console.log(JSON.stringify(rows, null, 2));
176
+ return;
177
+ }
178
+ if (rows.length === 0) {
179
+ process.stderr.write(`${C.y}(无日志:该时间窗内 ${args.service} 无输出,或 --grep 没命中)${C.n}\n`);
180
+ return;
181
+ }
182
+ for (const r of rows) {
183
+ // SLS 存的 content 已含容器时间戳 + stream(stdout/stderr)前缀,直接打印即可
184
+ process.stdout.write((r.content ?? JSON.stringify(r)) + '\n');
185
+ }
186
+ }
187
+ function main() {
188
+ let args;
189
+ try {
190
+ args = parseArgs(process.argv.slice(2));
191
+ }
192
+ catch (e) {
193
+ console.error(`${C.y}✗ ${e.message}${C.n}`);
194
+ process.exit(1);
195
+ }
196
+ try {
197
+ if (CN_ENVS.includes(args.env))
198
+ fetchCn(args);
199
+ else
200
+ fetchAws(args);
201
+ }
202
+ catch (e) {
203
+ console.error(`${C.y}✗ ${e.message}${C.n}`);
204
+ process.exit(1);
205
+ }
206
+ }
207
+ main();
@@ -0,0 +1,109 @@
1
+ # SPEC — dev-skills 运营 admin CLI 4-环境统一 + 账号禁用
2
+
3
+ > 状态:FINAL(r1+r2 fresh-agent review 已过;OPEN-1/2/3/5 闭环,OPEN-4 留 T0 验证)
4
+ > 作者:本次 session · 日期:2026-06-16
5
+ > 目标来源(用户原话,2026-06-15):"4个环境(stage, prod, cn-stage, cn-prod)的 grant 和 entitlement 的授予和撤销,以及状态查询。另外加一个账号禁用和恢复。同时支持手机号和邮箱。"
6
+ > 用户锁定:subscription=授予+查询(无撤销);entitlement=授予+撤销+查询;credits=授予(修手机号);ban/unban=4环境(admin-用户凭证)。命令面=`optima-account status|ban|unban` 聚合。status 含 credits 余额。
7
+
8
+ ## 1. 目标与范围(已锁定)
9
+
10
+ | 域 | 授予 | 撤销 | 状态查询 | 备注 |
11
+ |---|---|---|---|---|
12
+ | subscription | ✅ 已有 `grant-subscription` | ❌ 不做 | ✅ 并入 `account status` | billing 无 admin 撤销端点 |
13
+ | entitlement | ✅ 已有 | ✅ 已有 `refund-entitlement` | ✅ 已有 `admin/entitlements` | |
14
+ | credits | ✅ 已有 `grant-balance` | ❌ 无端点 | ✅ 并入 `account status` | **修缺陷:支持手机号** |
15
+ | account ban/unban | — | — | ✅ `account status` 显示 is_active/banned | ✅ 全新,4 环境 |
16
+
17
+ 4 环境:`stage`/`prod`/`cn-stage`/`cn-prod`。标识符:cn 支持 `phone/email/userId`,AWS `email` only(SSH 隧道 + M2M scope 限制)。
18
+ 不在范围:subscription 撤销、credits 撤销、AWS 手机号解析。
19
+
20
+ ## 2. 命令面(已定)
21
+
22
+ - `optima-grant-subscription <email|phone|userId> --plan --months --env` —— 授予会员(已支持 4 环境,本次仅随 `resolveTargetUser` 抽取而调整)。
23
+ - `optima-grant-balance <email|phone|userId> --amount --env` —— 授予 credits。**改**:接 `resolveTargetUser`(支持手机号),位置参数 identifier,`--email` 兼容别名。
24
+ - `optima-entitlement grant|revoke|list <email|phone|userId> --env` —— **改**:cn-prod + cn-stage(基于 main 抽取的 resolver)。
25
+ - `optima-account status|ban|unban <email|phone|userId> --env` —— **新 bin**:
26
+ - `status`(只读,聚合):订阅(membership-status) + 权益(admin/entitlements) + 账号状态(is_active/banned) + **credits 余额**。
27
+ - `ban --reason "..."`:user-auth `is_active=false`。prod/cn-prod confirm。**AWS 与 cn 都须在执行前打印解析到的目标账号(email/phone+userId)做反查回显**(见 R1)。
28
+ - `unban`。
29
+
30
+ ## 3. 每环境 鉴权 + 解析 矩阵
31
+
32
+ | 环境 | 身份 | userId 解析 | 解析 token | billing 写 | ban 写 |
33
+ |---|---|---|---|---|---|
34
+ | stage/prod (AWS) | email | RDS SSH 隧道 `resolveUserId(email)` | AWS Infisical | M2M client_credentials | **admin-用户 token(新)** |
35
+ | cn-prod/cn-stage (阿里云) | 手机号为主 | HTTP `/internal/users/lookup`(phone/email)+`getUserById`反查+`assertPhoneMatch` | M2M(cn, scope `internal:users:write`) | 同 M2M | **admin-用户 token(新)** |
36
+
37
+ **端点(已源码核实;impl T0 须逐环境真实请求复核,CLAUDE.md)**:
38
+ - 解析:user-auth `POST /api/v1/internal/users/lookup`、`GET /api/v1/internal/users/{id}`(M2M, verify_internal_service_token)。
39
+ - subscription 授予:billing `POST /api/billing/admin/grant-subscription`(requireAdminService/M2M)。
40
+ - subscription 状态:billing `GET /api/internal/users/{userId}/membership-status`(extractAnyServiceAuth/M2M)→ `{active, planId, status}`。**注意路径前缀 `/api/internal`,非 `/api/billing/admin`;用 callBilling(billing baseURL) 调(见 OPEN-4)**。
41
+ - entitlement:billing `POST /api/billing/admin/{grant,refund}-entitlement`、`GET /api/billing/admin/entitlements?userId=`。
42
+ - credits 授予:billing `POST /api/billing/admin/grant-credits`。
43
+ - credits 余额读取:**待 T0 确认 M2M 可读端点**(候选 billing internal/admin;若无则 status 的 credits 部分降级为"不可读"提示,不阻塞)。
44
+ - ban/unban:user-auth `POST /api/v1/admin/users/{id}/ban`(body `{reason}`)、`/unban`。鉴权 `get_current_admin_user`(role=ADMIN **用户** token,非 M2M)。
45
+
46
+ ## 4. resolveTargetUser:从 main 抽取(**修正 r1-HIGH-1**)
47
+
48
+ **基线认知(已核实)**:当前 **main(0.8.0) 已完整支持 cn-stage**——`grant-subscription.ts` 的 cn 分支是 `if (env === 'cn-prod' || env === 'cn-stage')`(main grant-subscription.ts:127),billing-http 有 cn-stage URL/token 分支。**PR #33 是更旧、更窄的产物**:它抽出的 `resolveTargetUser` 只判 `env === 'cn-prod'`(#33 grant-subscription.ts:85),**会回归 cn-stage**。
49
+
50
+ **做法**:不要"移植 #33 的 resolver"。而是**从 main 现有的 inline cn-prod||cn-stage 逻辑抽出** `resolveTargetUser(env, identifier)`,保留 cn-stage;丢弃 #33 的 cn-prod-only 版本,只取它的 entitlement 接线思路。动代码前先 `diff` main 与 #33 的 cn 分支确认无遗漏。
51
+
52
+ ```
53
+ kind = classifyIdentifier(identifier) # email/phone/userId
54
+ assertAwsEmailOnly(env, kind)
55
+ if env in {cn-prod, cn-stage}: # ← 必须含 cn-stage
56
+ userId = resolveByPhone|Email|userId
57
+ acct = getUserById(env, userId); 打印 🎯 目标账号
58
+ if kind == phone: assertPhoneMatch(...)
59
+ else (stage/prod):
60
+ userId = resolveUserId(email) via SSH 隧道
61
+ 打印 🎯 目标账号(email + userId) # ← R1:AWS 也要回显
62
+ ```
63
+ T2/T3/T5 全部依赖此函数(T1 先行)。
64
+
65
+ ## 5. ban/unban 鉴权设计
66
+
67
+ - M2M token 无 user_id → 调 ban 404。须 **admin 用户**(role=ADMIN) **password grant**。
68
+ - **复用 generate-test-token 已验证的 per-env public ROPC client**(修正 r1-HIGH-2,原 spec 的 `agent-portal-bc0osnsd` 作废):
69
+ - stage `commerce-cli-stage-ihbbwplz` / prod `commerce-cli-ecs-pro-i2r5of1h` / cn-prod `dev-skill-cli-cn-pro-acvkmcuq` / cn-stage `dev-skill-cli-cn-sta-3dvsxzdo`。
70
+ - userId 解析仍走 §4 `resolveTargetUser`(M2M lookup);**仅最后 ban/unban 调用换 admin-用户 token**(无 chicken-and-egg,r1 已确认)。
71
+ - 新增 `getAdminUserToken(env)`:读 admin email/password → password-grant → curl `oauth/token`。**独立缓存**,不复用 `billing-http.tokenCache`(M2M 缓存),避免冲突(R2)。
72
+ - 新增 `callUserAuthAsAdmin(env, method, path, body)`:base=`USER_AUTH_URLS[env]` + admin-用户 bearer。当前仅有 `callBilling`/`callSkills`,无 user-auth helper(R4)。
73
+
74
+ ### ban 语义(已实测 + 决策 B1)
75
+ - user-auth ban = `is_active=false` + ban 元数据。**已实测确认**:`verify_token`(oauth.py:267) 与 `get_current_user`(auth.py:13) **都不查 is_active**,verify_token 只认 `revoked_token:{jti}`(单 token)+ single-device session-superseded(需 flag)。→ **被 ban 用户已签发 access token 仍有效到过期**,ban 只挡新登录/刷新。
76
+ - `revoke-user` 端点(`POST /api/v1/admin/tokens/revoke-user/{id}`)**是 TODO 桩**:`admin_service.revoke_user_tokens` 只写 `user_tokens_revoked:{id}` 这个 Redis key,**verify_token 全程不读它**(全仓仅 admin.py:365 一处写、零处读)→ 调它等于没用。要真·立刻踢须改 user-auth verify_token(接上 user 级 revoke 时间戳 vs token iat 比对)+ 部署 4 环境。
77
+ - `is_active=false` 与账号软删除**共用同一标志**,仅靠 `banned_at` 区分。
78
+ - **决策 B1(用户定)**:本次 `account ban` 只做 `is_active=false`(名义禁用),**不链 revoke-user**(桩,无效)。help/输出须明示"非即时踢会话,活跃 token 过期后失效"。`account status` 用 `banned_at` 区分 ban vs 软删除。真·即时踢作为 user-auth 后续。
79
+
80
+ ### admin 账号凭证(OPEN-1,已实测解决)
81
+ - **4 环境统一一套凭证**:`admin@optima.chat` + **同一个 seed 密码**(1P item `okshqmbbtu4oes6jhjiz6byojm` "user-auth cn-prod admin (seed password)";亦见 1P "Optima-admin" item,URL=三个 admin portal)。**已实测**:该密码在 stage / prod / cn-prod / cn-stage 的 `oauth/token` password grant 全部返回 `role=admin` token(配对应 ROPC client,见上)。所有 4 环境 user-auth 均存在 `admin@optima.chat`(role=ADMIN, has_pw=true)。
82
+ - 说明:admin 账号日常虽走邮箱登录,但 DB seed 时种了密码(故可 ROPC);1P item 里的 `auth-cn.optima.chat` URL 已废(迁 yzsgo.com),仅 item 元数据陈旧,凭证本身有效;`auth.yzsgo.com/` 返回的 `{"service":"user-auth",...}` 是根路径 banner,非异常。
83
+ - **分发性(OPEN-1 已定)**:CLI 共享工具运行时从 **Infisical** 读该密码——落点 **`/shared-secrets/credentials`**(泛化凭证桶,区别于装 M2M 的 `oauth-clients`;已配好 stage/prod,键 USER_AUTH_ADMIN_EMAIL=admin@optima.chat / USER_AUTH_ADMIN_PASSWORD),键 `USER_AUTH_ADMIN_EMAIL` / `USER_AUTH_ADMIN_PASSWORD`。AWS Infisical 存一份(覆 stage/prod),cn Infisical 存一份(覆 cn-prod/cn-stage);账密 4 环境相同,值从 1P 灌入。`getAdminUserToken(env)` 用 `fetchInfisicalSecret`(AWS) / cn Infisical(cn) 读,配各环境 ROPC client password-grant。
84
+
85
+ ## 6. 验收标准
86
+ - `npm run build`(tsc)绿;repo 既有 lint/test 通过。
87
+ - 单测:classify/assertPhoneMatch/assertAwsEmailOnly 保持;新增 `resolveTargetUser` 的 cn-stage 路由用例、`getAdminUserToken` 缓存隔离用例(mock)。
88
+ - 各命令 ×4 环境 `--help` 正确。
89
+ - T0 真实只读复核 6 类端点 ×相关环境(非 404/200)。
90
+ - `account status <真实用户>` 4 环境各跑一次(只读);ban/unban 在 stage/cn-stage 用测试账号实跑闭环;prod/cn-prod 仅用户授权下。
91
+ - 向后兼容 `--email`。
92
+
93
+ ## 7. 任务分解
94
+ - **T0**:逐环境真实请求复核 6 类端点(含 membership-status on AWS、credits 余额读取候选)。
95
+ - **T1**:从 main 抽 `resolveTargetUser`(含 cn-stage)+ 单测。(被 T2/T3/T5 依赖)
96
+ - **T2**:grant-balance 接 `resolveTargetUser`(identifier + 手机号)。依赖 T1。
97
+ - **T3**:entitlement grant/list/revoke 落地(cn-prod+cn-stage,基于 main 抽取)。依赖 T1。
98
+ - **T4**:`optima-account status`(membership-status + entitlements + 账号状态 + credits 余额聚合,只读)。依赖 T1。
99
+ - **T5**:`getAdminUserToken`(读 `/shared-secrets/credentials`,独立缓存)+ `callUserAuthAsAdmin` + `optima-account ban/unban`(B1:仅 is_active;含 AWS 反查回显;help 注明非即时踢会话)。依赖 T1 + Infisical 灌值。
100
+ - **T6**:SKILL.md/README/bin 注册/help 同步 4 环境。
101
+ - **T7**:final review + 真实环境验证 + **处理 PR #33(关闭并改正描述)**。
102
+
103
+ > **#33 需求保证**:丢弃 #33 不丢需求。#33 实现的部分(entitlement 接 cn-prod、位置参数 `<email|phone|userId>` + `--email` 兼容别名、抽 `resolveTargetUser`)由 T1+T3 在 main 基线上重新落地,并补齐 cn-stage。final review 须逐条对照用户原始需求清单确认无遗漏。
104
+
105
+ ## OPEN ITEMS
106
+ - **OPEN-4(验证项,T0 解决)**:membership-status 经 callBilling+M2M 在 AWS 是否可读;credits 余额的 M2M 读取端点(r2 已初验:无 M2M 读他人余额端点 → status 的 credits 部分大概率降级为"不可读"提示,不阻塞)。
107
+ - ~~OPEN-1~~ 已定:admin 凭证 `/shared-secrets/credentials`(USER_AUTH_ADMIN_EMAIL/PASSWORD),4 环境统一 admin@optima.chat+seed pw(实测可用)。
108
+ - ~~OPEN-2~~ 已定:`optima-account` 聚合。 ~~OPEN-3~~ 已定:status 含 credits 余额。
109
+ - ~~OPEN-5~~ 已定:B1——ban 仅 is_active,不链 revoke-user(桩无效);即时踢列 user-auth 后续。
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "@optima-chat/dev-skills",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Claude Code Skills for Optima development team - cross-environment collaboration tools",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "optima-dev-skills": "bin/cli.js",
8
+ "optima-account": "dist/bin/helpers/account.js",
8
9
  "optima-entitlement": "dist/bin/helpers/entitlement.js",
9
10
  "optima-generate-test-token": "dist/bin/helpers/generate-test-token.js",
10
11
  "optima-grant-balance": "dist/bin/helpers/grant-balance.js",
11
12
  "optima-grant-subscription": "dist/bin/helpers/grant-subscription.js",
13
+ "optima-logs": "dist/bin/helpers/logs.js",
12
14
  "optima-plugin": "dist/bin/helpers/plugin.js",
13
15
  "optima-discount": "dist/bin/helpers/discount.js",
14
16
  "optima-product": "dist/bin/helpers/product.js",