@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.
- package/.claude/commands/logs.md +27 -47
- package/.claude/skills/account/SKILL.md +61 -0
- package/.claude/skills/entitlement/SKILL.md +55 -0
- package/.claude/skills/grant-balance/SKILL.md +3 -3
- package/.claude/skills/grant-subscription/SKILL.md +4 -4
- package/.codex/skills/account/SKILL.md +61 -0
- package/.codex/skills/entitlement/SKILL.md +55 -0
- package/.codex/skills/grant-balance/SKILL.md +3 -3
- package/.codex/skills/grant-subscription/SKILL.md +1 -1
- package/README.md +6 -0
- package/bin/cli.js +1 -1
- package/bin/helpers/account.ts +146 -0
- package/bin/helpers/billing-http.ts +105 -19
- package/bin/helpers/entitlement/grant.ts +18 -12
- package/bin/helpers/entitlement/list.ts +15 -11
- package/bin/helpers/entitlement/revoke.ts +18 -14
- package/bin/helpers/grant-balance.ts +17 -21
- package/bin/helpers/grant-subscription.ts +47 -21
- package/bin/helpers/logs.ts +200 -0
- package/dist/bin/helpers/account.js +141 -0
- package/dist/bin/helpers/billing-http.js +99 -19
- package/dist/bin/helpers/entitlement/grant.js +20 -12
- package/dist/bin/helpers/entitlement/list.js +17 -11
- package/dist/bin/helpers/entitlement/revoke.js +20 -14
- package/dist/bin/helpers/grant-balance.js +15 -20
- package/dist/bin/helpers/grant-subscription.js +37 -20
- package/dist/bin/helpers/logs.js +207 -0
- package/docs/.admin-cli-4env/spec.md +109 -0
- package/package.json +3 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* optima-logs —— 一条命令直取服务日志,四环境统一,用 --env 区分。
|
|
4
|
+
*
|
|
5
|
+
* stage / prod : AWS CloudWatch(`aws logs tail /ecs/<svc>-<env>`)
|
|
6
|
+
* cn-prod / cn-stage: 阿里云 SLS 直连(`aliyun sls GetLogs`)
|
|
7
|
+
*
|
|
8
|
+
* cn 的关键改进:旧流程要 SSH 进 buildbox 再调 SAE `DescribeInstanceLog`,
|
|
9
|
+
* 只能看实例**当前缓冲**(重启即丢、不能检索)。现在 cn-prod/cn-stage 全部
|
|
10
|
+
* 服务已接 SLS,GetLogs 是公网控制面 API,本机 `aliyun-optima` profile 直连即可:
|
|
11
|
+
* - 免 buildbox 跳板
|
|
12
|
+
* - 支持时间窗(--since)+ 关键词检索(--grep)+ 历史(重启不丢)
|
|
13
|
+
*
|
|
14
|
+
* 前置:
|
|
15
|
+
* AWS → 已配 aws CLI 凭证(ap-southeast-1)
|
|
16
|
+
* cn → 已配 aliyun CLI profile `aliyun-optima`(cn-beijing)
|
|
17
|
+
*
|
|
18
|
+
* 用法:
|
|
19
|
+
* optima-logs gateway-core # 默认 cn-prod,最近 1h,100 行
|
|
20
|
+
* optima-logs gateway-core --env cn-stage
|
|
21
|
+
* optima-logs user-auth --env prod --since 2h
|
|
22
|
+
* optima-logs commerce-backend --grep error -n 200
|
|
23
|
+
* optima-logs gateway-core --since 30m --json # 机器可读
|
|
24
|
+
*
|
|
25
|
+
* service == SLS logstore == AWS 日志组 `/ecs/<svc>-<env>` 的 <svc>(同名)。
|
|
26
|
+
*/
|
|
27
|
+
import { execFileSync } from 'child_process';
|
|
28
|
+
|
|
29
|
+
const ALIYUN_ACCOUNT = '1911493506120573';
|
|
30
|
+
const ALIYUN_PROFILE = 'aliyun-optima';
|
|
31
|
+
const ALIYUN_REGION = 'cn-beijing';
|
|
32
|
+
const AWS_REGION = 'ap-southeast-1';
|
|
33
|
+
|
|
34
|
+
const AWS_ENVS = ['stage', 'prod'] as const;
|
|
35
|
+
const CN_ENVS = ['cn-prod', 'cn-stage'] as const;
|
|
36
|
+
type Env = (typeof AWS_ENVS)[number] | (typeof CN_ENVS)[number];
|
|
37
|
+
const ALL_ENVS: Env[] = [...AWS_ENVS, ...CN_ENVS];
|
|
38
|
+
|
|
39
|
+
const C = { g: '\x1b[32m', y: '\x1b[33m', b: '\x1b[34m', d: '\x1b[2m', n: '\x1b[0m' };
|
|
40
|
+
|
|
41
|
+
interface Args {
|
|
42
|
+
service: string;
|
|
43
|
+
env: Env;
|
|
44
|
+
lines: number;
|
|
45
|
+
since: string; // 原样字符串,如 1h / 30m / 2d
|
|
46
|
+
grep?: string;
|
|
47
|
+
json: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const HELP = `Usage: optima-logs <service> [options]
|
|
51
|
+
|
|
52
|
+
四环境统一查日志,cn 直连阿里云 SLS(免 buildbox),AWS 走 CloudWatch。
|
|
53
|
+
|
|
54
|
+
Required:
|
|
55
|
+
<service> 服务名(== SLS logstore == /ecs/<svc>-<env> 的 <svc>)
|
|
56
|
+
|
|
57
|
+
Optional:
|
|
58
|
+
--env <e> stage | prod | cn-prod | cn-stage (default: cn-prod)
|
|
59
|
+
--lines, -n <N> 返回行数 (default: 100)
|
|
60
|
+
--since <dur> 时间窗,如 30m / 2h / 1d / 3600(秒) (default: 1h)
|
|
61
|
+
--grep <kw> 关键词检索(cn=SLS query / aws=filter-pattern)
|
|
62
|
+
--json 原始 JSON 输出(接管道)
|
|
63
|
+
--help, -h 显示本帮助
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
optima-logs gateway-core
|
|
67
|
+
optima-logs user-auth --env prod --since 2h
|
|
68
|
+
optima-logs commerce-backend --env cn-prod --grep error -n 200`;
|
|
69
|
+
|
|
70
|
+
/** 把 30m / 2h / 1d / 纯秒 解析成秒数;同时回填给 aws 用的带单位字符串。 */
|
|
71
|
+
function parseSince(raw: string): { sec: number; awsStr: string } {
|
|
72
|
+
const m = raw.match(/^(\d+)([smhdw]?)$/);
|
|
73
|
+
if (!m) throw new Error(`--since 格式非法: ${raw}(用 30m / 2h / 1d / 纯秒)`);
|
|
74
|
+
const n = parseInt(m[1], 10);
|
|
75
|
+
const unit = m[2] || 's';
|
|
76
|
+
const mult: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
|
|
77
|
+
return { sec: n * mult[unit], awsStr: `${n}${unit}` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseArgs(argv: string[]): Args {
|
|
81
|
+
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
82
|
+
console.log(HELP);
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
const out: Partial<Args> = { env: 'cn-prod', lines: 100, since: '1h', json: false };
|
|
86
|
+
const positional: string[] = [];
|
|
87
|
+
for (let i = 0; i < argv.length; i++) {
|
|
88
|
+
const a = argv[i];
|
|
89
|
+
const next = argv[i + 1];
|
|
90
|
+
switch (a) {
|
|
91
|
+
case '--env': out.env = next as Env; i++; break;
|
|
92
|
+
case '--lines': case '-n': {
|
|
93
|
+
const v = parseInt(next, 10);
|
|
94
|
+
if (isNaN(v) || v <= 0) throw new Error('--lines 需要正整数');
|
|
95
|
+
out.lines = v; i++; break;
|
|
96
|
+
}
|
|
97
|
+
case '--since': out.since = next; i++; break;
|
|
98
|
+
case '--grep': out.grep = next; i++; break;
|
|
99
|
+
case '--json': out.json = true; break;
|
|
100
|
+
default:
|
|
101
|
+
if (a.startsWith('-')) throw new Error(`未知参数: ${a}`);
|
|
102
|
+
positional.push(a);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (positional.length === 0) throw new Error('缺少 <service>(见 --help)');
|
|
106
|
+
if (positional.length > 1) throw new Error(`多余参数: ${positional.slice(1).join(' ')}`);
|
|
107
|
+
out.service = positional[0];
|
|
108
|
+
if (!ALL_ENVS.includes(out.env as Env)) {
|
|
109
|
+
throw new Error(`--env 非法: ${out.env}(可选 ${ALL_ENVS.join(' | ')})`);
|
|
110
|
+
}
|
|
111
|
+
return out as Args;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function run(cmd: string, args: string[]): string {
|
|
115
|
+
return execFileSync(cmd, args, { encoding: 'utf-8', maxBuffer: 128 * 1024 * 1024 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** AWS CloudWatch:aws logs tail /ecs/<svc>-<env>。 */
|
|
119
|
+
function fetchAws(args: Args): void {
|
|
120
|
+
const group = `/ecs/${args.service}-${args.env}`;
|
|
121
|
+
const { awsStr } = parseSince(args.since);
|
|
122
|
+
const cmd = ['logs', 'tail', group, '--since', awsStr, '--region', AWS_REGION, '--format', args.json ? 'json' : 'short'];
|
|
123
|
+
if (args.grep) cmd.push('--filter-pattern', args.grep);
|
|
124
|
+
process.stderr.write(`${C.d}# AWS CloudWatch ${group} (since ${awsStr}${args.grep ? `, grep "${args.grep}"` : ''})${C.n}\n`);
|
|
125
|
+
try {
|
|
126
|
+
process.stdout.write(run('aws', cmd));
|
|
127
|
+
} catch (e: any) {
|
|
128
|
+
if (/ResourceNotFoundException/.test(e.stderr || e.message || '')) {
|
|
129
|
+
throw new Error(`日志组不存在: ${group}\n 确认服务名/环境对,或该服务未部署在 ${args.env}。`);
|
|
130
|
+
}
|
|
131
|
+
throw new Error(e.stderr || e.message);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 阿里云 SLS:aliyun sls GetLogs,project=optima-<env>-<account>,logstore=service。 */
|
|
136
|
+
function fetchCn(args: Args): void {
|
|
137
|
+
const project = `optima-${args.env}-${ALIYUN_ACCOUNT}`;
|
|
138
|
+
const { sec } = parseSince(args.since);
|
|
139
|
+
const now = Math.floor(Date.now() / 1000);
|
|
140
|
+
const from = now - sec;
|
|
141
|
+
const cmd = [
|
|
142
|
+
'sls', 'GetLogs',
|
|
143
|
+
'--project', project,
|
|
144
|
+
'--logstore', args.service,
|
|
145
|
+
'--from', String(from),
|
|
146
|
+
'--to', String(now),
|
|
147
|
+
'--line', String(args.lines),
|
|
148
|
+
'--reverse', 'true', // 先拿最新 N 条
|
|
149
|
+
'--region', ALIYUN_REGION,
|
|
150
|
+
'--profile', ALIYUN_PROFILE,
|
|
151
|
+
];
|
|
152
|
+
if (args.grep) { cmd.push('--query', args.grep); }
|
|
153
|
+
process.stderr.write(`${C.d}# 阿里云 SLS ${project}/${args.service} (since ${args.since}${args.grep ? `, query "${args.grep}"` : ''})${C.n}\n`);
|
|
154
|
+
|
|
155
|
+
let raw: string;
|
|
156
|
+
try {
|
|
157
|
+
raw = run('aliyun', cmd);
|
|
158
|
+
} catch (e: any) {
|
|
159
|
+
const msg = e.stderr || e.message || '';
|
|
160
|
+
if (/LogStoreNotExist|ProjectNotExist/.test(msg)) {
|
|
161
|
+
throw new Error(`SLS logstore 不存在: ${project}/${args.service}\n 确认服务名对,或该服务未接 SLS。列全部:\n aliyun sls ListLogStores --project ${project} --region ${ALIYUN_REGION} --profile ${ALIYUN_PROFILE}`);
|
|
162
|
+
}
|
|
163
|
+
throw new Error(msg);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const rows: Array<Record<string, string>> = JSON.parse(raw || '[]');
|
|
167
|
+
// reverse=true 取到的是新→旧,翻回旧→新便于阅读
|
|
168
|
+
rows.reverse();
|
|
169
|
+
if (args.json) {
|
|
170
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (rows.length === 0) {
|
|
174
|
+
process.stderr.write(`${C.y}(无日志:该时间窗内 ${args.service} 无输出,或 --grep 没命中)${C.n}\n`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const r of rows) {
|
|
178
|
+
// SLS 存的 content 已含容器时间戳 + stream(stdout/stderr)前缀,直接打印即可
|
|
179
|
+
process.stdout.write((r.content ?? JSON.stringify(r)) + '\n');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function main(): void {
|
|
184
|
+
let args: Args;
|
|
185
|
+
try {
|
|
186
|
+
args = parseArgs(process.argv.slice(2));
|
|
187
|
+
} catch (e: any) {
|
|
188
|
+
console.error(`${C.y}✗ ${e.message}${C.n}`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
if (CN_ENVS.includes(args.env as any)) fetchCn(args);
|
|
193
|
+
else fetchAws(args);
|
|
194
|
+
} catch (e: any) {
|
|
195
|
+
console.error(`${C.y}✗ ${e.message}${C.n}`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
main();
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
// optima-account:以用户为中心的运营 admin 操作。
|
|
5
|
+
// · status — 只读聚合(订阅 + 权益)。M2M。
|
|
6
|
+
// · ban / unban — user-auth 账号禁用/恢复。需 admin-用户 token(非 M2M)。
|
|
7
|
+
//
|
|
8
|
+
// 标识符 <email|phone|userId>:cn-prod/cn-stage 支持三种,AWS stage/prod 仅 email
|
|
9
|
+
// (见 resolveTargetUser)。两个生产环境(prod/cn-prod)ban/unban 前会要求确认。
|
|
10
|
+
const grant_subscription_1 = require("./grant-subscription");
|
|
11
|
+
const billing_http_1 = require("./billing-http");
|
|
12
|
+
const confirm_prompt_1 = require("./confirm-prompt");
|
|
13
|
+
function parseArgs(argv, opts = {}) {
|
|
14
|
+
const out = { env: 'stage', reason: null, yes: false };
|
|
15
|
+
for (let i = 0; i < argv.length; i++) {
|
|
16
|
+
const a = argv[i];
|
|
17
|
+
const next = argv[i + 1];
|
|
18
|
+
if (a === '--env') {
|
|
19
|
+
out.env = next;
|
|
20
|
+
i++;
|
|
21
|
+
}
|
|
22
|
+
else if (a === '--reason') {
|
|
23
|
+
out.reason = next;
|
|
24
|
+
i++;
|
|
25
|
+
}
|
|
26
|
+
else if (a === '--yes') {
|
|
27
|
+
out.yes = true;
|
|
28
|
+
}
|
|
29
|
+
else if (a.startsWith('--')) {
|
|
30
|
+
throw new Error(`Unknown arg: ${a}`);
|
|
31
|
+
}
|
|
32
|
+
else if (out.identifier) {
|
|
33
|
+
throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
out.identifier = a;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!out.identifier)
|
|
40
|
+
throw new Error('target user required: <email|phone|userId>');
|
|
41
|
+
(0, billing_http_1.validateEnvCnProd)(out.env);
|
|
42
|
+
if (opts.reason && !out.reason)
|
|
43
|
+
throw new Error('--reason required for ban');
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
async function runStatus(argv) {
|
|
47
|
+
const { identifier, env } = parseArgs(argv);
|
|
48
|
+
const { userId, identity } = await (0, grant_subscription_1.resolveTargetUser)(env, identifier);
|
|
49
|
+
// 订阅(membership-status,M2M;路径前缀 /api/internal 经 billing base URL)
|
|
50
|
+
let sub = '(读取失败)';
|
|
51
|
+
try {
|
|
52
|
+
const { body } = await (0, billing_http_1.callBilling)(env, 'GET', `/api/internal/users/${encodeURIComponent(userId)}/membership-status`);
|
|
53
|
+
sub = `active=${body.active} plan=${body.planId} status=${body.status}`;
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
sub = `(读取失败: ${e.message})`;
|
|
57
|
+
}
|
|
58
|
+
// 权益(admin/entitlements,M2M)
|
|
59
|
+
let ents = '(读取失败)';
|
|
60
|
+
try {
|
|
61
|
+
const { body } = await (0, billing_http_1.callBilling)(env, 'GET', `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`);
|
|
62
|
+
const rows = body.entitlements ?? [];
|
|
63
|
+
ents = rows.length ? rows.map((r) => `${r.productKey}(${r.status})`).join(', ') : '(无)';
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
ents = `(读取失败: ${e.message})`;
|
|
67
|
+
}
|
|
68
|
+
console.log(`\n=== 账号状态 [${env.toUpperCase()}] ===`);
|
|
69
|
+
console.log(`userId : ${userId}`);
|
|
70
|
+
console.log(`手机 : ${identity.phone || '(无)'}`);
|
|
71
|
+
console.log(`email : ${identity.email || '(无)'}`);
|
|
72
|
+
console.log(`订阅 : ${sub}`);
|
|
73
|
+
console.log(`权益 : ${ents}`);
|
|
74
|
+
console.log(`禁用态/credits 余额: 暂未纳入(banned_at 与 credits 无 M2M 读取端点,见 optima-dev-skills#36)\n`);
|
|
75
|
+
}
|
|
76
|
+
async function runBan(argv) {
|
|
77
|
+
const { identifier, env, reason, yes } = parseArgs(argv, { reason: true });
|
|
78
|
+
const { userId, identity } = await (0, grant_subscription_1.resolveTargetUser)(env, identifier);
|
|
79
|
+
await (0, confirm_prompt_1.confirmIfProd)(env, `Action: BAN userId=${userId} (手机=${identity.phone || '(无)'} email=${identity.email || '(无)'}) on ${env.toUpperCase()}\nReason: ${reason}`, yes);
|
|
80
|
+
console.log(`\n🚫 Banning ${identifier} (userId=${userId})...`);
|
|
81
|
+
const res = await (0, billing_http_1.callUserAuthAsAdmin)(env, 'POST', `/api/v1/admin/users/${encodeURIComponent(userId)}/ban`, { reason });
|
|
82
|
+
console.log(`✓ Banned (HTTP ${res.status})`);
|
|
83
|
+
console.log('⚠️ 注意:ban 仅置 is_active=false——挡新登录/刷新,但**不会立即失效已签发的 access token**(活跃会话到 token 过期才失效)。即时踢会话需 user-auth 后续支持。\n');
|
|
84
|
+
}
|
|
85
|
+
async function runUnban(argv) {
|
|
86
|
+
const { identifier, env, yes } = parseArgs(argv);
|
|
87
|
+
const { userId, identity } = await (0, grant_subscription_1.resolveTargetUser)(env, identifier);
|
|
88
|
+
await (0, confirm_prompt_1.confirmIfProd)(env, `Action: UNBAN userId=${userId} (手机=${identity.phone || '(无)'} email=${identity.email || '(无)'}) on ${env.toUpperCase()}`, yes);
|
|
89
|
+
console.log(`\n♻️ Unbanning ${identifier} (userId=${userId})...`);
|
|
90
|
+
const res = await (0, billing_http_1.callUserAuthAsAdmin)(env, 'POST', `/api/v1/admin/users/${encodeURIComponent(userId)}/unban`);
|
|
91
|
+
console.log(`✓ Unbanned (HTTP ${res.status})\n`);
|
|
92
|
+
}
|
|
93
|
+
function printHelp() {
|
|
94
|
+
console.log(`Usage: optima-account <subcommand> <email|phone|userId> [options]
|
|
95
|
+
|
|
96
|
+
Subcommands:
|
|
97
|
+
status 只读聚合:订阅(membership) + 权益(entitlements)
|
|
98
|
+
ban 禁用账号(user-auth is_active=false)。需 --reason
|
|
99
|
+
unban 恢复账号
|
|
100
|
+
|
|
101
|
+
Target user: <email|phone|userId> (positional). phone/userId only on
|
|
102
|
+
cn-prod / cn-stage; AWS stage/prod resolve email only.
|
|
103
|
+
|
|
104
|
+
Options:
|
|
105
|
+
--reason "..." Ban reason (required for ban; stored on the user)
|
|
106
|
+
--yes Skip prod/cn-prod confirmation prompt
|
|
107
|
+
--env <env> Environment: stage, prod, cn-prod, cn-stage (default: stage)
|
|
108
|
+
|
|
109
|
+
Notes:
|
|
110
|
+
· ban/unban 用 admin-用户 token(Infisical /shared-secrets/credentials)。
|
|
111
|
+
· ban 非即时踢会话:仅挡新登录/刷新,活跃 token 过期后失效。
|
|
112
|
+
· 禁用原因/banned_at 与 credits 余额暂不在 status 显示(见 #36)。
|
|
113
|
+
|
|
114
|
+
Examples:
|
|
115
|
+
optima-account status 18898654855 --env cn-prod
|
|
116
|
+
optima-account ban user@example.com --reason "abuse" --env prod
|
|
117
|
+
optima-account unban 18898654855 --env cn-prod`);
|
|
118
|
+
}
|
|
119
|
+
async function main() {
|
|
120
|
+
const [, , subcommand, ...rest] = process.argv;
|
|
121
|
+
if (!subcommand || subcommand === '-h' || subcommand === '--help') {
|
|
122
|
+
printHelp();
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
switch (subcommand) {
|
|
126
|
+
case 'status':
|
|
127
|
+
await runStatus(rest);
|
|
128
|
+
break;
|
|
129
|
+
case 'ban':
|
|
130
|
+
await runBan(rest);
|
|
131
|
+
break;
|
|
132
|
+
case 'unban':
|
|
133
|
+
await runUnban(rest);
|
|
134
|
+
break;
|
|
135
|
+
default:
|
|
136
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
137
|
+
printHelp();
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
main().catch((err) => { console.error('\n❌ Error:', err.message); process.exit(1); });
|
|
@@ -5,8 +5,9 @@ exports.validateEnvCnProd = validateEnvCnProd;
|
|
|
5
5
|
exports.getServiceToken = getServiceToken;
|
|
6
6
|
exports.callBilling = callBilling;
|
|
7
7
|
exports.callSkills = callSkills;
|
|
8
|
+
exports.getAdminUserToken = getAdminUserToken;
|
|
9
|
+
exports.callUserAuthAsAdmin = callUserAuthAsAdmin;
|
|
8
10
|
exports.resolveUserIdByEmail = resolveUserIdByEmail;
|
|
9
|
-
exports.resolveUserIdByEmailAnyEnv = resolveUserIdByEmailAnyEnv;
|
|
10
11
|
exports.resolveUserIdByPhone = resolveUserIdByPhone;
|
|
11
12
|
exports.getUserById = getUserById;
|
|
12
13
|
const child_process_1 = require("child_process");
|
|
@@ -220,6 +221,103 @@ async function callBilling(env, method, path, body) {
|
|
|
220
221
|
async function callSkills(env, method, path, body) {
|
|
221
222
|
return callService(getSkillsUrl(env), env, method, path, body);
|
|
222
223
|
}
|
|
224
|
+
// ───── Admin-USER token (ROPC password grant) ───────────────────────────────
|
|
225
|
+
// Distinct from getServiceToken (M2M client_credentials): user-auth's /admin/*
|
|
226
|
+
// endpoints gate on get_current_admin_user — a real role=ADMIN *user* — which an
|
|
227
|
+
// M2M token (no user_id) can't satisfy. So ban/unban mint a password-grant token
|
|
228
|
+
// for the seeded admin account. Per-env ROPC client = the same public client
|
|
229
|
+
// generate-test-token uses; admin email/password live in Infisical
|
|
230
|
+
// /shared-secrets/credentials. Cached separately from the M2M tokenCache.
|
|
231
|
+
const adminTokenCache = {};
|
|
232
|
+
const ADMIN_ROPC_CLIENT = {
|
|
233
|
+
stage: 'commerce-cli-stage-ihbbwplz',
|
|
234
|
+
prod: 'commerce-cli-ecs-pro-i2r5of1h',
|
|
235
|
+
'cn-prod': 'dev-skill-cli-cn-pro-acvkmcuq',
|
|
236
|
+
'cn-stage': 'dev-skill-cli-cn-sta-3dvsxzdo',
|
|
237
|
+
};
|
|
238
|
+
const ADMIN_CREDS_PATH = '/shared-secrets/credentials';
|
|
239
|
+
async function getAdminUserToken(env) {
|
|
240
|
+
if (adminTokenCache[env])
|
|
241
|
+
return adminTokenCache[env];
|
|
242
|
+
let email;
|
|
243
|
+
let password;
|
|
244
|
+
if (env === 'cn-prod' || env === 'cn-stage') {
|
|
245
|
+
// cn Infisical (separate instance; needs INFISICAL_CN_EMAIL/PASSWORD at runtime).
|
|
246
|
+
const cnTok = (0, db_utils_1.getCnInfisicalToken)();
|
|
247
|
+
const creds = (0, db_utils_1.getCnSecrets)(cnTok, ADMIN_CREDS_PATH, false, env === 'cn-stage' ? 'staging' : 'prod');
|
|
248
|
+
email = creds['USER_AUTH_ADMIN_EMAIL'];
|
|
249
|
+
password = creds['USER_AUTH_ADMIN_PASSWORD'];
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
const cfg = (0, db_utils_1.getInfisicalConfig)();
|
|
253
|
+
const tok = (0, db_utils_1.getInfisicalToken)(cfg);
|
|
254
|
+
email = (0, infisical_secrets_1.fetchInfisicalSecret)(env, ADMIN_CREDS_PATH, 'USER_AUTH_ADMIN_EMAIL', cfg, tok);
|
|
255
|
+
password = (0, infisical_secrets_1.fetchInfisicalSecret)(env, ADMIN_CREDS_PATH, 'USER_AUTH_ADMIN_PASSWORD', cfg, tok);
|
|
256
|
+
}
|
|
257
|
+
if (!email || !password) {
|
|
258
|
+
throw new Error(`admin 凭证缺失(Infisical ${ADMIN_CREDS_PATH} 的 USER_AUTH_ADMIN_EMAIL/PASSWORD,env=${env})`);
|
|
259
|
+
}
|
|
260
|
+
const clientId = ADMIN_ROPC_CLIENT[env];
|
|
261
|
+
const authUrl = USER_AUTH_URLS[env];
|
|
262
|
+
if (!clientId || !authUrl)
|
|
263
|
+
throw new Error(`Unknown env: ${env}`);
|
|
264
|
+
// fetch (not execSync curl) so the password never lands in a shell command line.
|
|
265
|
+
const form = new URLSearchParams({
|
|
266
|
+
grant_type: 'password',
|
|
267
|
+
username: email,
|
|
268
|
+
password,
|
|
269
|
+
client_id: clientId,
|
|
270
|
+
});
|
|
271
|
+
const res = await fetch(`${authUrl}/api/v1/oauth/token`, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
274
|
+
body: form,
|
|
275
|
+
});
|
|
276
|
+
const text = await res.text();
|
|
277
|
+
let parsed;
|
|
278
|
+
try {
|
|
279
|
+
parsed = JSON.parse(text);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
throw new Error(`user-auth admin token endpoint returned non-JSON (${env}): ${text.slice(0, 200)}`);
|
|
283
|
+
}
|
|
284
|
+
if (!parsed.access_token) {
|
|
285
|
+
throw new Error(`admin-user token mint failed (${env}): ${text.slice(0, 200)}`);
|
|
286
|
+
}
|
|
287
|
+
adminTokenCache[env] = parsed.access_token;
|
|
288
|
+
return parsed.access_token;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Authenticated call to user-auth as the admin USER (role=ADMIN), for the
|
|
292
|
+
* /api/v1/admin/* endpoints that getServiceToken's M2M token can't reach
|
|
293
|
+
* (ban/unban). Mirrors callService's shape; single retry on 5xx.
|
|
294
|
+
*/
|
|
295
|
+
async function callUserAuthAsAdmin(env, method, path, body) {
|
|
296
|
+
const authUrl = USER_AUTH_URLS[env];
|
|
297
|
+
if (!authUrl)
|
|
298
|
+
throw new Error(`Unknown env: ${env}`);
|
|
299
|
+
const token = await getAdminUserToken(env);
|
|
300
|
+
const doFetch = async () => fetch(`${authUrl}${path}`, {
|
|
301
|
+
method,
|
|
302
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
303
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
304
|
+
});
|
|
305
|
+
let res = await doFetch();
|
|
306
|
+
if (res.status >= 500)
|
|
307
|
+
res = await doFetch();
|
|
308
|
+
const text = await res.text();
|
|
309
|
+
if (!res.ok) {
|
|
310
|
+
throw new Error(formatServiceError(res.status, res.statusText, text));
|
|
311
|
+
}
|
|
312
|
+
let parsed;
|
|
313
|
+
try {
|
|
314
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
throw new Error(`user-auth admin returned non-JSON 2xx body: ${text.slice(0, 200)}`);
|
|
318
|
+
}
|
|
319
|
+
return { status: res.status, body: parsed };
|
|
320
|
+
}
|
|
223
321
|
/**
|
|
224
322
|
* Resolve a user's id by email via user-auth's internal lookup endpoint
|
|
225
323
|
* (POST /api/v1/internal/users/lookup). cn-prod only: AWS envs resolve via
|
|
@@ -257,24 +355,6 @@ async function resolveUserIdByEmail(env, email) {
|
|
|
257
355
|
console.log(`✓ Found user: ${parsed.user_id}`);
|
|
258
356
|
return parsed.user_id;
|
|
259
357
|
}
|
|
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
|
-
}
|
|
278
358
|
/**
|
|
279
359
|
* Resolve a user's id by phone via user-auth's internal lookup endpoint
|
|
280
360
|
* (POST /api/v1/internal/users/lookup with {phone}). Mirrors
|
|
@@ -3,18 +3,19 @@ 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 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 grant
|
|
9
|
+
console.log(`Usage: optima-entitlement grant <email|phone|userId> --product-key <slug> --justification "..." [options]
|
|
9
10
|
|
|
10
11
|
Required:
|
|
11
|
-
|
|
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
|
--justification "..." Required by billing (400 otherwise); stored on entitlement.justification
|
|
14
15
|
|
|
15
16
|
Optional:
|
|
16
|
-
--yes Skip prod confirmation prompt (no-op on stage)
|
|
17
|
-
--env stage|prod|cn-prod|cn-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
|
Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
|
|
20
21
|
process.exit(0);
|
|
@@ -25,9 +26,9 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
|
|
|
25
26
|
const next = argv[i + 1];
|
|
26
27
|
switch (a) {
|
|
27
28
|
case '--email':
|
|
28
|
-
out.
|
|
29
|
+
out.identifier = next;
|
|
29
30
|
i++;
|
|
30
|
-
break;
|
|
31
|
+
break; // back-compat alias for the positional identifier
|
|
31
32
|
case '--product-key':
|
|
32
33
|
out.productKey = next;
|
|
33
34
|
i++;
|
|
@@ -43,11 +44,16 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
|
|
|
43
44
|
out.env = next;
|
|
44
45
|
i++;
|
|
45
46
|
break;
|
|
46
|
-
default:
|
|
47
|
+
default:
|
|
48
|
+
if (a.startsWith('--'))
|
|
49
|
+
throw new Error(`Unknown arg: ${a}`);
|
|
50
|
+
if (out.identifier)
|
|
51
|
+
throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
|
|
52
|
+
out.identifier = a;
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
|
-
if (!out.
|
|
50
|
-
throw new Error('--email
|
|
55
|
+
if (!out.identifier)
|
|
56
|
+
throw new Error('target user required (<email|phone|userId> positional, or --email)');
|
|
51
57
|
if (!out.productKey)
|
|
52
58
|
throw new Error('--product-key required');
|
|
53
59
|
if (!out.justification)
|
|
@@ -57,9 +63,11 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
|
|
|
57
63
|
async function runGrant(argv) {
|
|
58
64
|
const args = parseArgs(argv);
|
|
59
65
|
(0, billing_http_1.validateEnvCnProd)(args.env);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
// Shared resolver: accepts email/phone/userId, reverse-verifies + phone-asserts
|
|
67
|
+
// on cn-prod, email-only via SSH tunnel on AWS (gateway#923).
|
|
68
|
+
const { userId } = await (0, grant_subscription_1.resolveTargetUser)(args.env, args.identifier);
|
|
69
|
+
await (0, confirm_prompt_1.confirmIfProd)(args.env, `Action: GRANT product '${args.productKey}' to ${args.identifier} (userId=${userId}) on ${args.env.toUpperCase()}\nJustification: ${args.justification}`, args.yes);
|
|
70
|
+
console.log(`\n🎁 Granting ${args.productKey} to ${args.identifier}...`);
|
|
63
71
|
const res = await (0, billing_http_1.callBilling)(args.env, 'POST', '/api/billing/admin/grant-entitlement', {
|
|
64
72
|
userId,
|
|
65
73
|
productKey: args.productKey,
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.runList = runList;
|
|
4
4
|
const billing_http_1 = require("../billing-http");
|
|
5
|
+
const grant_subscription_1 = require("../grant-subscription");
|
|
5
6
|
function parseArgs(argv) {
|
|
6
7
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
7
|
-
console.log(`Usage: optima-entitlement list
|
|
8
|
+
console.log(`Usage: optima-entitlement list <email|phone|userId> [options]
|
|
8
9
|
|
|
9
10
|
Required:
|
|
10
|
-
|
|
11
|
+
<email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
|
|
11
12
|
|
|
12
13
|
Optional:
|
|
13
|
-
--env stage|prod|cn-prod|cn-stage
|
|
14
|
+
--env stage|prod|cn-prod|cn-stage (default: stage)`);
|
|
14
15
|
process.exit(0);
|
|
15
16
|
}
|
|
16
17
|
const out = { env: 'stage' };
|
|
@@ -19,33 +20,38 @@ Optional:
|
|
|
19
20
|
const next = argv[i + 1];
|
|
20
21
|
switch (a) {
|
|
21
22
|
case '--email':
|
|
22
|
-
out.
|
|
23
|
+
out.identifier = next;
|
|
23
24
|
i++;
|
|
24
|
-
break;
|
|
25
|
+
break; // back-compat alias for the positional identifier
|
|
25
26
|
case '--env':
|
|
26
27
|
out.env = next;
|
|
27
28
|
i++;
|
|
28
29
|
break;
|
|
29
|
-
default:
|
|
30
|
+
default:
|
|
31
|
+
if (a.startsWith('--'))
|
|
32
|
+
throw new Error(`Unknown arg: ${a}`);
|
|
33
|
+
if (out.identifier)
|
|
34
|
+
throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
|
|
35
|
+
out.identifier = a;
|
|
30
36
|
}
|
|
31
37
|
}
|
|
32
|
-
if (!out.
|
|
33
|
-
throw new Error('--email
|
|
38
|
+
if (!out.identifier)
|
|
39
|
+
throw new Error('target user required (<email|phone|userId> positional, or --email)');
|
|
34
40
|
return out;
|
|
35
41
|
}
|
|
36
42
|
async function runList(argv) {
|
|
37
43
|
const args = parseArgs(argv);
|
|
38
44
|
(0, billing_http_1.validateEnvCnProd)(args.env);
|
|
39
|
-
const userId = await (0,
|
|
45
|
+
const { userId } = await (0, grant_subscription_1.resolveTargetUser)(args.env, args.identifier);
|
|
40
46
|
const res = await (0, billing_http_1.callBilling)(args.env, 'GET', `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`);
|
|
41
47
|
const rows = res.body.entitlements ?? [];
|
|
42
48
|
if (rows.length === 0) {
|
|
43
|
-
console.log(`(no entitlements for ${args.
|
|
49
|
+
console.log(`(no entitlements for ${args.identifier} on ${args.env})`);
|
|
44
50
|
return;
|
|
45
51
|
}
|
|
46
52
|
// Newest first per spec
|
|
47
53
|
rows.sort((a, b) => b.purchasedAt.localeCompare(a.purchasedAt));
|
|
48
|
-
console.log(`${rows.length} entitlement(s) for ${args.
|
|
54
|
+
console.log(`${rows.length} entitlement(s) for ${args.identifier}:\n`);
|
|
49
55
|
console.log('id'.padEnd(38) + ' | ' + 'productKey'.padEnd(32) + ' | ' + 'status'.padEnd(9) + ' | ' + 'source'.padEnd(12) + ' | purchasedAt | refundedAt');
|
|
50
56
|
console.log('-'.repeat(140));
|
|
51
57
|
for (const r of rows) {
|