@optima-chat/dev-skills 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/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/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 +60 -29
- 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 +50 -28
- package/docs/.admin-cli-4env/spec.md +109 -0
- package/package.json +2 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { callBilling, validateEnvCnProd
|
|
1
|
+
import { callBilling, validateEnvCnProd } from '../billing-http';
|
|
2
2
|
import { confirmIfProd } from '../confirm-prompt';
|
|
3
|
+
import { resolveTargetUser } from '../grant-subscription';
|
|
3
4
|
|
|
4
5
|
interface GrantArgs {
|
|
5
|
-
|
|
6
|
+
identifier: string;
|
|
6
7
|
productKey: string;
|
|
7
8
|
justification: string;
|
|
8
9
|
yes: boolean;
|
|
@@ -11,16 +12,16 @@ interface GrantArgs {
|
|
|
11
12
|
|
|
12
13
|
function parseArgs(argv: string[]): GrantArgs {
|
|
13
14
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
14
|
-
console.log(`Usage: optima-entitlement grant
|
|
15
|
+
console.log(`Usage: optima-entitlement grant <email|phone|userId> --product-key <slug> --justification "..." [options]
|
|
15
16
|
|
|
16
17
|
Required:
|
|
17
|
-
|
|
18
|
+
<email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
|
|
18
19
|
--product-key <productKey>
|
|
19
20
|
--justification "..." Required by billing (400 otherwise); stored on entitlement.justification
|
|
20
21
|
|
|
21
22
|
Optional:
|
|
22
|
-
--yes Skip prod confirmation prompt (no-op on stage)
|
|
23
|
-
--env stage|prod|cn-prod|cn-stage
|
|
23
|
+
--yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
|
|
24
|
+
--env stage|prod|cn-prod|cn-stage (default: stage)
|
|
24
25
|
|
|
25
26
|
Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
|
|
26
27
|
process.exit(0);
|
|
@@ -30,15 +31,18 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
|
|
|
30
31
|
const a = argv[i];
|
|
31
32
|
const next = argv[i + 1];
|
|
32
33
|
switch (a) {
|
|
33
|
-
case '--email': out.
|
|
34
|
+
case '--email': out.identifier = next; i++; break; // back-compat alias for the positional identifier
|
|
34
35
|
case '--product-key': out.productKey = next; i++; break;
|
|
35
36
|
case '--justification': out.justification = next; i++; break;
|
|
36
37
|
case '--yes': out.yes = true; break;
|
|
37
38
|
case '--env': out.env = next; i++; break;
|
|
38
|
-
default:
|
|
39
|
+
default:
|
|
40
|
+
if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
|
|
41
|
+
if (out.identifier) throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
|
|
42
|
+
out.identifier = a;
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
|
-
if (!out.
|
|
45
|
+
if (!out.identifier) throw new Error('target user required (<email|phone|userId> positional, or --email)');
|
|
42
46
|
if (!out.productKey) throw new Error('--product-key required');
|
|
43
47
|
if (!out.justification) throw new Error('--justification required (billing returns 400 otherwise)');
|
|
44
48
|
return out as GrantArgs;
|
|
@@ -48,15 +52,17 @@ export async function runGrant(argv: string[]): Promise<void> {
|
|
|
48
52
|
const args = parseArgs(argv);
|
|
49
53
|
validateEnvCnProd(args.env);
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
// Shared resolver: accepts email/phone/userId, reverse-verifies + phone-asserts
|
|
56
|
+
// on cn-prod, email-only via SSH tunnel on AWS (gateway#923).
|
|
57
|
+
const { userId } = await resolveTargetUser(args.env, args.identifier);
|
|
52
58
|
|
|
53
59
|
await confirmIfProd(
|
|
54
60
|
args.env,
|
|
55
|
-
`Action: GRANT product '${args.productKey}' to
|
|
61
|
+
`Action: GRANT product '${args.productKey}' to ${args.identifier} (userId=${userId}) on ${args.env.toUpperCase()}\nJustification: ${args.justification}`,
|
|
56
62
|
args.yes,
|
|
57
63
|
);
|
|
58
64
|
|
|
59
|
-
console.log(`\n🎁 Granting ${args.productKey} to ${args.
|
|
65
|
+
console.log(`\n🎁 Granting ${args.productKey} to ${args.identifier}...`);
|
|
60
66
|
const res = await callBilling(args.env, 'POST', '/api/billing/admin/grant-entitlement', {
|
|
61
67
|
userId,
|
|
62
68
|
productKey: args.productKey,
|
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
import { callBilling, validateEnvCnProd
|
|
1
|
+
import { callBilling, validateEnvCnProd } from '../billing-http';
|
|
2
|
+
import { resolveTargetUser } from '../grant-subscription';
|
|
2
3
|
|
|
3
4
|
interface ListArgs {
|
|
4
|
-
|
|
5
|
+
identifier: string;
|
|
5
6
|
env: string;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
function parseArgs(argv: string[]): ListArgs {
|
|
9
10
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
10
|
-
console.log(`Usage: optima-entitlement list
|
|
11
|
+
console.log(`Usage: optima-entitlement list <email|phone|userId> [options]
|
|
11
12
|
|
|
12
13
|
Required:
|
|
13
|
-
|
|
14
|
+
<email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
|
|
14
15
|
|
|
15
16
|
Optional:
|
|
16
|
-
--env stage|prod|cn-prod|cn-stage
|
|
17
|
+
--env stage|prod|cn-prod|cn-stage (default: stage)`);
|
|
17
18
|
process.exit(0);
|
|
18
19
|
}
|
|
19
20
|
const out: Partial<ListArgs> = { env: 'stage' };
|
|
@@ -21,12 +22,15 @@ Optional:
|
|
|
21
22
|
const a = argv[i];
|
|
22
23
|
const next = argv[i + 1];
|
|
23
24
|
switch (a) {
|
|
24
|
-
case '--email': out.
|
|
25
|
+
case '--email': out.identifier = next; i++; break; // back-compat alias for the positional identifier
|
|
25
26
|
case '--env': out.env = next; i++; break;
|
|
26
|
-
default:
|
|
27
|
+
default:
|
|
28
|
+
if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
|
|
29
|
+
if (out.identifier) throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
|
|
30
|
+
out.identifier = a;
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
|
-
if (!out.
|
|
33
|
+
if (!out.identifier) throw new Error('target user required (<email|phone|userId> positional, or --email)');
|
|
30
34
|
return out as ListArgs;
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -43,7 +47,7 @@ export async function runList(argv: string[]): Promise<void> {
|
|
|
43
47
|
const args = parseArgs(argv);
|
|
44
48
|
validateEnvCnProd(args.env);
|
|
45
49
|
|
|
46
|
-
const userId = await
|
|
50
|
+
const { userId } = await resolveTargetUser(args.env, args.identifier);
|
|
47
51
|
|
|
48
52
|
const res = await callBilling<{ entitlements: EntitlementRow[] }>(
|
|
49
53
|
args.env,
|
|
@@ -53,12 +57,12 @@ export async function runList(argv: string[]): Promise<void> {
|
|
|
53
57
|
|
|
54
58
|
const rows = res.body.entitlements ?? [];
|
|
55
59
|
if (rows.length === 0) {
|
|
56
|
-
console.log(`(no entitlements for ${args.
|
|
60
|
+
console.log(`(no entitlements for ${args.identifier} on ${args.env})`);
|
|
57
61
|
return;
|
|
58
62
|
}
|
|
59
63
|
// Newest first per spec
|
|
60
64
|
rows.sort((a, b) => b.purchasedAt.localeCompare(a.purchasedAt));
|
|
61
|
-
console.log(`${rows.length} entitlement(s) for ${args.
|
|
65
|
+
console.log(`${rows.length} entitlement(s) for ${args.identifier}:\n`);
|
|
62
66
|
console.log('id'.padEnd(38) + ' | ' + 'productKey'.padEnd(32) + ' | ' + 'status'.padEnd(9) + ' | ' + 'source'.padEnd(12) + ' | purchasedAt | refundedAt');
|
|
63
67
|
console.log('-'.repeat(140));
|
|
64
68
|
for (const r of rows) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { callBilling, validateEnvCnProd
|
|
1
|
+
import { callBilling, validateEnvCnProd } from '../billing-http';
|
|
2
2
|
import { confirmIfProd } from '../confirm-prompt';
|
|
3
|
+
import { resolveTargetUser } from '../grant-subscription';
|
|
3
4
|
|
|
4
5
|
interface RevokeArgs {
|
|
5
|
-
|
|
6
|
+
identifier: string;
|
|
6
7
|
productKey: string;
|
|
7
8
|
reason: string;
|
|
8
9
|
yes: boolean;
|
|
@@ -18,16 +19,16 @@ interface EntitlementRow {
|
|
|
18
19
|
|
|
19
20
|
function parseArgs(argv: string[]): RevokeArgs {
|
|
20
21
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
21
|
-
console.log(`Usage: optima-entitlement revoke
|
|
22
|
+
console.log(`Usage: optima-entitlement revoke <email|phone|userId> --product-key <slug> --reason "..." [options]
|
|
22
23
|
|
|
23
24
|
Required:
|
|
24
|
-
|
|
25
|
+
<email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
|
|
25
26
|
--product-key <productKey>
|
|
26
27
|
--reason "..." Required by billing (400 otherwise); stored on entitlement.refundReason
|
|
27
28
|
|
|
28
29
|
Optional:
|
|
29
|
-
--yes Skip prod confirmation prompt (no-op on stage)
|
|
30
|
-
--env stage|prod|cn-prod|cn-stage
|
|
30
|
+
--yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
|
|
31
|
+
--env stage|prod|cn-prod|cn-stage (default: stage)
|
|
31
32
|
|
|
32
33
|
Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
|
|
33
34
|
error pointing to the right reversal flow.`);
|
|
@@ -38,15 +39,18 @@ error pointing to the right reversal flow.`);
|
|
|
38
39
|
const a = argv[i];
|
|
39
40
|
const next = argv[i + 1];
|
|
40
41
|
switch (a) {
|
|
41
|
-
case '--email': out.
|
|
42
|
+
case '--email': out.identifier = next; i++; break; // back-compat alias for the positional identifier
|
|
42
43
|
case '--product-key': out.productKey = next; i++; break;
|
|
43
44
|
case '--reason': out.reason = next; i++; break;
|
|
44
45
|
case '--yes': out.yes = true; break;
|
|
45
46
|
case '--env': out.env = next; i++; break;
|
|
46
|
-
default:
|
|
47
|
+
default:
|
|
48
|
+
if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
|
|
49
|
+
if (out.identifier) throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
|
|
50
|
+
out.identifier = a;
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
|
-
if (!out.
|
|
53
|
+
if (!out.identifier) throw new Error('target user required (<email|phone|userId> positional, or --email)');
|
|
50
54
|
if (!out.productKey) throw new Error('--product-key required');
|
|
51
55
|
if (!out.reason) throw new Error('--reason required (billing returns 400 otherwise)');
|
|
52
56
|
return out as RevokeArgs;
|
|
@@ -60,7 +64,7 @@ export async function runRevoke(argv: string[]): Promise<void> {
|
|
|
60
64
|
const args = parseArgs(argv);
|
|
61
65
|
validateEnvCnProd(args.env);
|
|
62
66
|
|
|
63
|
-
const userId = await
|
|
67
|
+
const { userId } = await resolveTargetUser(args.env, args.identifier);
|
|
64
68
|
|
|
65
69
|
// Step 1: Fetch user's entitlements
|
|
66
70
|
const listRes = await callBilling<{ entitlements: EntitlementRow[] }>(
|
|
@@ -75,10 +79,10 @@ export async function runRevoke(argv: string[]): Promise<void> {
|
|
|
75
79
|
|
|
76
80
|
// Step 3: Validate count (partial unique index enforces ≤1 ACTIVE)
|
|
77
81
|
if (matches.length === 0) {
|
|
78
|
-
throw new Error(`no active entitlement for (user=${args.
|
|
82
|
+
throw new Error(`no active entitlement for (user=${args.identifier}, product=${args.productKey}) on ${args.env}`);
|
|
79
83
|
}
|
|
80
84
|
if (matches.length > 1) {
|
|
81
|
-
throw new Error(`unexpected: ${matches.length} ACTIVE entitlements for (user, product) — partial unique constraint should prevent this. Inspect with: optima-entitlement list
|
|
85
|
+
throw new Error(`unexpected: ${matches.length} ACTIVE entitlements for (user, product) — partial unique constraint should prevent this. Inspect with: optima-entitlement list ${args.identifier}`);
|
|
82
86
|
}
|
|
83
87
|
const target = matches[0];
|
|
84
88
|
|
|
@@ -89,7 +93,7 @@ export async function runRevoke(argv: string[]): Promise<void> {
|
|
|
89
93
|
|
|
90
94
|
await confirmIfProd(
|
|
91
95
|
args.env,
|
|
92
|
-
`Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for
|
|
96
|
+
`Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for ${args.identifier} (userId=${userId}) on ${args.env.toUpperCase()}\nReason: ${args.reason}`,
|
|
93
97
|
args.yes,
|
|
94
98
|
);
|
|
95
99
|
|
|
@@ -102,7 +106,7 @@ export async function runRevoke(argv: string[]): Promise<void> {
|
|
|
102
106
|
// this branch only ever runs for ADMIN_GRANT (priceCents=0) — Stripe
|
|
103
107
|
// refund path in billing (admin-products.ts:296-307) is gated on
|
|
104
108
|
// source=PAYMENT and won't trigger here.
|
|
105
|
-
console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.
|
|
109
|
+
console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.identifier}...`);
|
|
106
110
|
const res = await callBilling(args.env, 'POST', '/api/billing/admin/refund-entitlement', {
|
|
107
111
|
entitlementId: target.id,
|
|
108
112
|
refundReason: args.reason,
|
|
@@ -5,17 +5,20 @@
|
|
|
5
5
|
// 改调 billing 服务态端点(grantCredits → bonus 积分)。
|
|
6
6
|
// ⚠️ 语义变化:旧 wallet granted 无期限;积分 bonus 桶标准 30 天有效期。
|
|
7
7
|
import { randomUUID } from 'crypto';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { callBilling, validateEnvCnProd } from './billing-http';
|
|
9
|
+
import { resolveTargetUser } from './grant-subscription';
|
|
10
10
|
|
|
11
|
-
function parseArgs(args: string[]): {
|
|
11
|
+
function parseArgs(args: string[]): { identifier: string; amountUsd: number; description: string | null; env: string } {
|
|
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,13 +27,12 @@ Options:
|
|
|
24
27
|
|
|
25
28
|
Examples:
|
|
26
29
|
optima-grant-balance user@example.com --amount 5 --env prod
|
|
27
|
-
optima-grant-balance
|
|
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
34
|
|
|
33
|
-
const
|
|
35
|
+
const identifier = args[0];
|
|
34
36
|
let amountUsd = 0;
|
|
35
37
|
let description: string | null = null;
|
|
36
38
|
let env = 'stage';
|
|
@@ -47,25 +49,19 @@ Examples:
|
|
|
47
49
|
}
|
|
48
50
|
validateEnvCnProd(env);
|
|
49
51
|
|
|
50
|
-
return {
|
|
52
|
+
return { identifier, amountUsd, description, env };
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
async function main() {
|
|
54
|
-
const {
|
|
56
|
+
const { identifier, amountUsd, description, env } = parseArgs(process.argv.slice(2));
|
|
55
57
|
|
|
56
|
-
console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${
|
|
58
|
+
console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${identifier} [${env.toUpperCase()}]\n`);
|
|
57
59
|
if (description) console.log(` Reason: ${description}`);
|
|
58
60
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
userId = await resolveUserIdByEmail(env, email);
|
|
64
|
-
} else {
|
|
65
|
-
const infisicalConfig = getInfisicalConfig();
|
|
66
|
-
const token = getInfisicalToken(infisicalConfig);
|
|
67
|
-
userId = await resolveUserId(email, env, infisicalConfig, token);
|
|
68
|
-
}
|
|
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 resolveTargetUser(env, identifier);
|
|
69
65
|
|
|
70
66
|
// 幂等键 per-invocation 生成、callBilling 的 5xx retry 复用同 body —— 「已
|
|
71
67
|
// commit 但响应 5xx」场景重试不双发(billing spec R2-M3)。
|
|
@@ -80,7 +76,7 @@ async function main() {
|
|
|
80
76
|
);
|
|
81
77
|
|
|
82
78
|
console.log(`✓ Granted ${body.credits} credits (lot ${body.lotId})`);
|
|
83
|
-
console.log(`\n✅ Done! ${
|
|
79
|
+
console.log(`\n✅ Done! ${identifier} received ${body.credits} bonus credits (expires in 30 days)\n`);
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
main().catch(error => {
|
|
@@ -64,15 +64,16 @@ export function assertAwsEmailOnly(env: string, kind: 'email' | 'phone' | 'userI
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
67
|
+
// All envs use canonical plan ids (no market suffix). billing canonicalized
|
|
68
|
+
// plan ids — dropped the `-cn` suffix; market is now carried by deployment-level
|
|
69
|
+
// BILLING_MARKET + the plan's `currency` column, not the id (optima-billing#181).
|
|
70
|
+
// cn envs additionally have the CN-only `free` tier. Per-env whitelist guards
|
|
71
|
+
// against granting a plan absent in that env.
|
|
70
72
|
const PLANS_BY_ENV: Record<string, string[]> = {
|
|
71
73
|
stage: ['trial', 'starter', 'pro', 'enterprise'],
|
|
72
74
|
prod: ['trial', 'starter', 'pro', 'enterprise'],
|
|
73
|
-
'cn-prod': ['trial', 'starter
|
|
74
|
-
|
|
75
|
-
'cn-stage': ['trial', 'starter-cn', 'pro-cn', 'enterprise-cn'],
|
|
75
|
+
'cn-prod': ['trial', 'starter', 'pro', 'enterprise', 'free'],
|
|
76
|
+
'cn-stage': ['trial', 'starter', 'pro', 'enterprise', 'free'],
|
|
76
77
|
};
|
|
77
78
|
|
|
78
79
|
function parseArgs(args: string[]): { identifier: string; plan: string; months: number; env: string } {
|
|
@@ -81,7 +82,8 @@ function parseArgs(args: string[]): { identifier: string; plan: string; months:
|
|
|
81
82
|
|
|
82
83
|
Options:
|
|
83
84
|
--plan <id> Plan: trial, starter, pro, enterprise (default: pro)
|
|
84
|
-
cn-prod/cn-stage
|
|
85
|
+
cn-prod/cn-stage additionally allow: free
|
|
86
|
+
(legacy *-cn ids are accepted and normalized to canonical)
|
|
85
87
|
--months <n> Duration in months (default: 1)
|
|
86
88
|
--env <env> Environment: stage, prod, cn-prod, cn-stage (default: stage)
|
|
87
89
|
-h, --help Show this help`);
|
|
@@ -100,7 +102,10 @@ Options:
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
validateEnvCnProd(env);
|
|
103
|
-
|
|
105
|
+
// billing canonicalized plan ids (dropped the -cn suffix, optima-billing#181).
|
|
106
|
+
// Default to canonical `pro`; accept legacy `*-cn` input (operator muscle
|
|
107
|
+
// memory) and normalize so it still resolves after the -cn rows are deleted.
|
|
108
|
+
plan = (plan ?? 'pro').replace(/-cn$/, '');
|
|
104
109
|
const allowed = PLANS_BY_ENV[env];
|
|
105
110
|
if (!allowed.includes(plan)) {
|
|
106
111
|
console.error(`Unknown plan for ${env}: ${plan}. Available: ${allowed.join(', ')}`);
|
|
@@ -111,22 +116,37 @@ Options:
|
|
|
111
116
|
return { identifier, plan, months, env };
|
|
112
117
|
}
|
|
113
118
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a CLI identifier (`<email|phone|userId>`) to a userId + verified
|
|
121
|
+
* account identity, handling the AWS-vs-cn split. Shared by grant-subscription,
|
|
122
|
+
* grant-balance, the optima-entitlement subcommands, and optima-account so all
|
|
123
|
+
* four accept the same identifier forms (gateway#923: phone/userId on cn).
|
|
124
|
+
*
|
|
125
|
+
* - AWS (stage/prod): email-only via the RDS SSH tunnel; no internal HTTP
|
|
126
|
+
* reverse-verify (the AWS dev-skills token lacks internal:users:write).
|
|
127
|
+
* - cn-prod / cn-stage: no tunnel into the Aliyun RDS — classify → resolve via
|
|
128
|
+
* user-auth internal endpoints → getUserById reverse-verify → phone-assert.
|
|
129
|
+
* cn-stage shares cn-prod's HTTP path (same internal endpoints, cn-stage M2M
|
|
130
|
+
* token); the split is AWS-vs-cn, NOT prod-vs-stage.
|
|
131
|
+
*
|
|
132
|
+
* Prints a `🎯 目标账号` line in BOTH branches before returning, so a wrong
|
|
133
|
+
* userId is caught by eye before any destructive call (ban/grant/revoke).
|
|
134
|
+
*/
|
|
135
|
+
export async function resolveTargetUser(
|
|
136
|
+
env: string,
|
|
137
|
+
identifier: string,
|
|
138
|
+
): Promise<{
|
|
139
|
+
userId: string;
|
|
140
|
+
kind: 'email' | 'phone' | 'userId';
|
|
141
|
+
identity: { phone: string | null; email: string | null };
|
|
142
|
+
}> {
|
|
117
143
|
const kind = classifyIdentifier(identifier);
|
|
118
144
|
assertAwsEmailOnly(env, kind);
|
|
119
|
-
console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${kind}) for ${months} month(s) [${env.toUpperCase()}]\n`);
|
|
120
|
-
|
|
121
|
-
// identity is the verified target account to print on success. AWS keeps the
|
|
122
|
-
// pre-change behavior (email-only, no internal HTTP reverse-verify); only
|
|
123
|
-
// cn-prod / cn-stage run the full classify→resolve→getUserById→phone-assert防呆 path.
|
|
124
|
-
let userId: string;
|
|
125
|
-
let identity: { phone: string | null; email: string | null };
|
|
126
145
|
|
|
127
146
|
if (env === 'cn-prod' || env === 'cn-stage') {
|
|
128
147
|
// cn-prod / cn-stage have no SSH tunnel into the Aliyun RDS — resolve via
|
|
129
148
|
// user-auth, and the dev-skills token carries internal:users:write for lookups.
|
|
149
|
+
let userId: string;
|
|
130
150
|
if (kind === 'userId') {
|
|
131
151
|
userId = identifier;
|
|
132
152
|
} else if (kind === 'phone') {
|
|
@@ -136,28 +156,39 @@ async function main() {
|
|
|
136
156
|
}
|
|
137
157
|
|
|
138
158
|
// Reverse-verify: fetch and loudly print the target account identity
|
|
139
|
-
// before
|
|
159
|
+
// before any mutation, so a wrong userId is caught by eye (gateway#923).
|
|
140
160
|
const acct = await getUserById(env, userId);
|
|
141
161
|
console.log(
|
|
142
162
|
`🎯 目标账号: userId=${userId} 手机=${acct.phone || '(无)'} email=${acct.email || '(无)'} 当前plan=${acct.current_plan || '?'}`,
|
|
143
163
|
);
|
|
144
164
|
|
|
145
165
|
// Hard assertion: a phone-input grant must land on an account whose phone
|
|
146
|
-
// matches. Runs BEFORE
|
|
166
|
+
// matches. Runs BEFORE the caller's mutation — a mismatch aborts.
|
|
147
167
|
if (kind === 'phone') {
|
|
148
168
|
assertPhoneMatch(identifier, acct.phone);
|
|
149
169
|
}
|
|
150
|
-
identity
|
|
151
|
-
} else {
|
|
152
|
-
// AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No
|
|
153
|
-
// internal HTTP reverse-verify (token lacks internal:users:write → 403).
|
|
154
|
-
const infisicalConfig = getInfisicalConfig();
|
|
155
|
-
const token = getInfisicalToken(infisicalConfig);
|
|
156
|
-
userId = await resolveUserId(identifier, env, infisicalConfig, token);
|
|
157
|
-
console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
|
|
158
|
-
identity = { phone: null, email: identifier };
|
|
170
|
+
return { userId, kind, identity: { phone: acct.phone, email: acct.email } };
|
|
159
171
|
}
|
|
160
172
|
|
|
173
|
+
// AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No internal
|
|
174
|
+
// HTTP reverse-verify (token lacks internal:users:write → 403), but still
|
|
175
|
+
// echo the resolved account so destructive ops have a confirmation line (R1).
|
|
176
|
+
const infisicalConfig = getInfisicalConfig();
|
|
177
|
+
const token = getInfisicalToken(infisicalConfig);
|
|
178
|
+
const userId = await resolveUserId(identifier, env, infisicalConfig, token);
|
|
179
|
+
console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
|
|
180
|
+
return { userId, kind, identity: { phone: null, email: identifier } };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function main() {
|
|
184
|
+
const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
|
|
185
|
+
|
|
186
|
+
console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${classifyIdentifier(identifier)}) for ${months} month(s) [${env.toUpperCase()}]\n`);
|
|
187
|
+
|
|
188
|
+
// Shared resolver: classify → resolve → reverse-verify echo → phone-assert
|
|
189
|
+
// (cn-prod/cn-stage), or email-only via SSH tunnel (AWS). See resolveTargetUser.
|
|
190
|
+
const { userId, identity } = await resolveTargetUser(env, identifier);
|
|
191
|
+
|
|
161
192
|
const { body } = await callBilling<{
|
|
162
193
|
success: boolean;
|
|
163
194
|
subscriptionId: string;
|
|
@@ -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); });
|