@optima-chat/dev-skills 0.16.6 → 0.16.8
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 +1 -1
- package/.claude/commands/query-db.md +6 -1
- package/.claude/skills/discount-codes/SKILL.md +4 -4
- package/.claude/skills/generate-test-token/SKILL.md +8 -4
- package/.claude/skills/logs/SKILL.md +5 -3
- package/.claude/skills/show-env/SKILL.md +1 -1
- package/.claude/skills/yzsgo-e2e/SKILL.md +19 -0
- package/.claude/skills/yzsgo-e2e/SYNC.md +26 -2
- package/.claude/skills/yzsgo-e2e/chat_driver.py +221 -47
- package/.claude/skills/yzsgo-e2e/pull_wire.py +39 -8
- package/.claude/skills/yzsgo-e2e/run_e2e.py +17 -2
- package/.codex/skills/generate-test-token/SKILL.md +3 -2
- package/.codex/skills/logs/SKILL.md +4 -2
- package/.codex/skills/show-env/SKILL.md +4 -2
- package/.codex/skills/yzsgo-e2e/SKILL.md +19 -0
- package/.codex/skills/yzsgo-e2e/SYNC.md +26 -2
- package/.codex/skills/yzsgo-e2e/chat_driver.py +221 -47
- package/.codex/skills/yzsgo-e2e/pull_wire.py +39 -8
- package/.codex/skills/yzsgo-e2e/run_e2e.py +17 -2
- package/AGENTS.md +1 -1
- package/bin/cli.js +3 -3
- package/bin/helpers/billing-http.ts +12 -3
- package/bin/helpers/db-utils.ts +8 -5
- package/bin/helpers/entitlement/grant.ts +5 -0
- package/bin/helpers/entitlement/revoke.ts +5 -0
- package/bin/helpers/generate-test-token.ts +40 -3
- package/bin/helpers/grant-credits.ts +8 -2
- package/bin/helpers/grant-subscription.ts +8 -4
- package/bin/helpers/operator.ts +9 -0
- package/bin/helpers/verify-health.ts +77 -29
- package/dist/bin/helpers/billing-http.js +9 -1
- package/dist/bin/helpers/db-utils.js +8 -2
- package/dist/bin/helpers/entitlement/grant.js +7 -0
- package/dist/bin/helpers/entitlement/revoke.js +7 -0
- package/dist/bin/helpers/generate-test-token.js +40 -3
- package/dist/bin/helpers/grant-credits.js +9 -2
- package/dist/bin/helpers/grant-subscription.js +9 -3
- package/dist/bin/helpers/operator.js +11 -0
- package/dist/bin/helpers/verify-health.js +83 -27
- package/package.json +1 -1
|
@@ -169,6 +169,19 @@ def render_conversation(conv, resps, deref, idx):
|
|
|
169
169
|
res_ids.add(b.get("tool_use_id"))
|
|
170
170
|
dangling = [(u, n) for u, n in use_ids.items() if u not in res_ids]
|
|
171
171
|
|
|
172
|
+
# 末 response 状态:审查靠它判「最终回复能不能核」。缺失时**必须显式标注**,
|
|
173
|
+
# 否则 transcript 静默停在末条 user 消息(常是超长 tool_result 截断处),
|
|
174
|
+
# 会被误读成「响应被截断吞了」(上游 #102 真根因:不是截断,是末 req 无 response 记录)。
|
|
175
|
+
lastresp = resps.get(last["callId"])
|
|
176
|
+
if not lastresp:
|
|
177
|
+
last_state = "⚠️ 末 request 无 response 记录(生成中断/未落盘/末轮是 continuation)——最终回复不可核"
|
|
178
|
+
elif lastresp.get("kind") == "error":
|
|
179
|
+
last_state = "⚠️ 末 response 是 error:" + json.dumps(deref(lastresp.get("error") or {}), ensure_ascii=False)[:160]
|
|
180
|
+
elif (lastresp.get("finalMessage") or {}).get("stopReason") == "max_tokens":
|
|
181
|
+
last_state = "⚠️ 末 response stop_reason=max_tokens(回复被截断,可能未说完)"
|
|
182
|
+
else:
|
|
183
|
+
last_state = "有(正常 response)"
|
|
184
|
+
|
|
172
185
|
L = [f"# 对话 #{idx} {ts0}",
|
|
173
186
|
"", f"**prompt**: {prompt[:200]}", "",
|
|
174
187
|
"## 事实卡(代码算的确定信息)",
|
|
@@ -176,9 +189,9 @@ def render_conversation(conv, resps, deref, idx):
|
|
|
176
189
|
f"- error 响应 {len(errs)}" + (f":{[json.dumps(deref(e.get('error') or {}),ensure_ascii=False)[:120] for e in errs]}" if errs else ""),
|
|
177
190
|
f"- stop_reason=max_tokens 的响应 {maxtok}",
|
|
178
191
|
f"- 悬空 tool_use(无匹配 result){len(dangling)}: {dangling[:5]}",
|
|
192
|
+
f"- 末 response: {last_state}",
|
|
179
193
|
"", "## 完整 transcript(末 req 全历史 + 末 response)", ""]
|
|
180
|
-
# 末 response
|
|
181
|
-
lastresp = resps.get(last["callId"])
|
|
194
|
+
# 末 response 拼到历史尾(仅 response 类才有 finalMessage 内容可拼)
|
|
182
195
|
if lastresp and lastresp.get("kind") == "response":
|
|
183
196
|
fm = lastresp.get("finalMessage") or {}
|
|
184
197
|
msgs.append({"role": "assistant", "content": fm.get("content", [])})
|
|
@@ -190,8 +203,15 @@ def render_conversation(conv, resps, deref, idx):
|
|
|
190
203
|
L.append(" " + _clip(c, CAP_TEXT))
|
|
191
204
|
elif isinstance(c, list):
|
|
192
205
|
for b in c:
|
|
193
|
-
|
|
194
|
-
|
|
206
|
+
try:
|
|
207
|
+
L.append(render_block(b, deref))
|
|
208
|
+
except Exception as e: # 单个块渲染失败不该中断后续消息(上游 #102 加固)
|
|
209
|
+
L.append(f" [渲染失败 {type(e).__name__}: {str(e)[:80]}]")
|
|
210
|
+
# transcript 结尾显式收口——末 response 非正常时补一行,让审查者绝不把静默结束当完整
|
|
211
|
+
resp_ok = bool(lastresp and lastresp.get("kind") == "response")
|
|
212
|
+
if not resp_ok:
|
|
213
|
+
L.append(f"\n--- [transcript 结束] {last_state} ---")
|
|
214
|
+
return prompt, ts0, len(errs), len(dangling), resp_ok, "\n".join(L)
|
|
195
215
|
|
|
196
216
|
|
|
197
217
|
def render_all(wire_root, out):
|
|
@@ -214,10 +234,10 @@ def render_all(wire_root, out):
|
|
|
214
234
|
index.append(f"\n## session `{sid}` — {len(convs)} 对话 / {len(reqs)} req\n")
|
|
215
235
|
for conv in convs:
|
|
216
236
|
gidx += 1
|
|
217
|
-
prompt, ts0, n_err, n_dang, md = render_conversation(conv, resps, deref, gidx)
|
|
237
|
+
prompt, ts0, n_err, n_dang, resp_ok, md = render_conversation(conv, resps, deref, gidx)
|
|
218
238
|
fn = f"{gidx:03d}.md"
|
|
219
239
|
open(os.path.join(conv_dir, fn), "w", encoding="utf-8").write(md)
|
|
220
|
-
flag = (" ⚠️err" if n_err else "") + (" ⚠️dangling" if n_dang else "")
|
|
240
|
+
flag = (" ⚠️err" if n_err else "") + (" ⚠️dangling" if n_dang else "") + ("" if resp_ok else " ⚠️无末response")
|
|
221
241
|
index.append(f"- [{fn}](conversations/{fn}) [{len(conv):>2} req] {prompt[:64]}{flag}")
|
|
222
242
|
idxpath = os.path.join(out, "index.md")
|
|
223
243
|
open(idxpath, "w", encoding="utf-8").write("\n".join(index))
|
|
@@ -278,9 +298,20 @@ def _ts_key(ts):
|
|
|
278
298
|
return None
|
|
279
299
|
|
|
280
300
|
|
|
281
|
-
def locate_conversation(index, started_ts, first_message):
|
|
301
|
+
def locate_conversation(index, started_ts, first_message, session_id=None):
|
|
282
302
|
"""按 (started_ts, first_message) 定位本次对话;禁用 ls -t。规则见 plan Task 3 Interfaces。
|
|
283
|
-
时间比较先把两侧 ISO(Z / +00:00、小数位宽不同)归一成 datetime,避免依赖字典序=数值序的脆弱假设。
|
|
303
|
+
时间比较先把两侧 ISO(Z / +00:00、小数位宽不同)归一成 datetime,避免依赖字典序=数值序的脆弱假设。
|
|
304
|
+
|
|
305
|
+
`session_id`(2026-09-09 起,鸭嘴兽支持并发任务后):驱动侧现在知道自己跑在**哪个 gateway
|
|
306
|
+
session** 上(一 tab 一 session,wire 目录名就是 sessionId),传进来就先把候选缩到该 session。
|
|
307
|
+
这是**确定性**定位,比 (时间, 首句) 的启发式可靠得多 —— 同一句 prompt 重跑多次时,
|
|
308
|
+
启发式只能靠时间戳挑,而并发跑的多个会话时间戳本来就交叠。
|
|
309
|
+
传了但该 session 在索引里一条都没有(wire 还没落盘/TTL 过期/拉的账号不对)→ **不回退**到
|
|
310
|
+
全局启发式:那样会安静地定位到别的 session 的对话,比返回 None 更糟。"""
|
|
311
|
+
if session_id:
|
|
312
|
+
index = [it for it in index if it.get("sid") == session_id]
|
|
313
|
+
if not index:
|
|
314
|
+
return None
|
|
284
315
|
key = (first_message or "").strip()[:40]
|
|
285
316
|
cands = []
|
|
286
317
|
for it in index:
|
|
@@ -85,7 +85,15 @@ def main():
|
|
|
85
85
|
|
|
86
86
|
started_ts = _utc_now()
|
|
87
87
|
answers = _parse_answers(args.answer)
|
|
88
|
+
# attach() 默认就自己开 tab 独占一个 gateway session("auto" 档)。两个好处——
|
|
89
|
+
# ① 不去抢用户已经开着的 chat tab(抢了会互相串台,见 chat_driver 模块 docstring);
|
|
90
|
+
# ② 拿到确定的 sessionId,后面按它**确定性**定位 wire 对话,不靠 (时间,首句) 猜。
|
|
91
|
+
# 该环境 multi-tab 没开(flag 默认关,cn-stage 未验)时 auto 档自己会降级复用已有 tab
|
|
92
|
+
# 并打 warn —— 单 driver 复用是安全的,功能不减,只是 wire 定位退回启发式。
|
|
88
93
|
d = chat_driver.ChatDriver().attach()
|
|
94
|
+
session_id = d.session_id # attach 后立刻取:放在 try 里的话,中途抛异常会留下未绑定名
|
|
95
|
+
if not d.tab_isolated:
|
|
96
|
+
print("[warn] 未能独占 tab(该环境 multi-tab 未开)—— wire 定位退回 (时间,首句) 启发式")
|
|
89
97
|
try:
|
|
90
98
|
d.new_conversation()
|
|
91
99
|
turns = []
|
|
@@ -99,13 +107,17 @@ def main():
|
|
|
99
107
|
wire_root = pull_wire.pull(args.user, since_days=args.since, out=args.out)
|
|
100
108
|
meta = {"env": args.env, "started_ts": started_ts, "first_message": args.message[0],
|
|
101
109
|
"expect": args.expect, "issue_repo": args.issue_repo, "turns": turns,
|
|
110
|
+
"session_id": session_id, # 本轮跑在哪个 gateway session(= wire 目录名)
|
|
102
111
|
"located": None, "located_reason": None}
|
|
103
112
|
wrote_prepped = False
|
|
104
113
|
if not wire_root:
|
|
105
114
|
meta["located_reason"] = "no_wire_session"
|
|
106
115
|
if wire_root:
|
|
107
116
|
index = pull_wire.emit_conversation_index(wire_root)
|
|
108
|
-
hit = pull_wire.locate_conversation(index, started_ts, args.message[0]
|
|
117
|
+
hit = pull_wire.locate_conversation(index, started_ts, args.message[0],
|
|
118
|
+
session_id=session_id)
|
|
119
|
+
if session_id and not hit:
|
|
120
|
+
meta["located_reason"] = f"session {session_id} 在 wire 索引里没有对话(未落盘/TTL 过期/账号不对)"
|
|
109
121
|
meta["located"] = hit
|
|
110
122
|
if hit:
|
|
111
123
|
sdir = os.path.join(wire_root, hit["sid"])
|
|
@@ -117,7 +129,10 @@ def main():
|
|
|
117
129
|
convs = pull_wire.segment(reqs, deref)
|
|
118
130
|
conv = select_conversation_in_session(convs, deref, hit)
|
|
119
131
|
if conv is not None:
|
|
120
|
-
|
|
132
|
+
# 6 元返回(上游 #102 起多了 resp_ok):末 response 不正常时 wire_md 结尾已显式收口,
|
|
133
|
+
# 但 meta 也记一份,报告层不必去 grep transcript 才知道「最终回复不可核」。
|
|
134
|
+
_, _, _, _, resp_ok, wire_md = pull_wire.render_conversation(conv, resps, deref, hit["gidx"])
|
|
135
|
+
meta["wire_last_response_ok"] = resp_ok
|
|
121
136
|
prepped = prep_conversation.merge_browser_evidence(wire_md, turns)
|
|
122
137
|
with open(os.path.join(args.out, "prepped.md"), "w", encoding="utf-8") as f:
|
|
123
138
|
f.write(prepped)
|
package/AGENTS.md
CHANGED
|
@@ -7,7 +7,7 @@ This repository provides shared development skills and CLI helpers for Optima en
|
|
|
7
7
|
Prefer the installed CLI tools over reimplementing long shell workflows:
|
|
8
8
|
|
|
9
9
|
- `optima-query-db <service> "<sql>" [environment]`
|
|
10
|
-
- `optima-show-env <service> <stage|prod> [options]`
|
|
10
|
+
- `optima-show-env <service> <stage|prod|cn-prod|cn-stage> [options]`
|
|
11
11
|
- `optima-generate-test-token [options]`
|
|
12
12
|
- `optima-grant-subscription <email> [options]`
|
|
13
13
|
- `optima-grant-credits <email|phone|userId> --credits <n> [options]`
|
package/bin/cli.js
CHANGED
|
@@ -32,8 +32,8 @@ switch (command) {
|
|
|
32
32
|
log('Available Commands:', 'yellow');
|
|
33
33
|
log(' optima-query-db <service> "<sql>" [env] Query database', 'cyan');
|
|
34
34
|
log(' optima-show-env <service> [env] Show service env vars', 'cyan');
|
|
35
|
-
log(' optima-verify-health <service> [--env
|
|
36
|
-
log(' optima-generate-test-token [--env
|
|
35
|
+
log(' optima-verify-health <service> [--env stage|prod|cn-prod|cn-stage|all] Probe L1-L5 上线健康', 'cyan');
|
|
36
|
+
log(' optima-generate-test-token [--env ci|stage|prod|cn-prod|cn-stage] Generate test token', 'cyan');
|
|
37
37
|
log(' optima-grant-credits <email|phone|userId> --credits <n> [--env] Grant credits (bonus, 30d)', 'cyan');
|
|
38
38
|
log(' optima-grant-subscription <email|phone|userId> --plan <p> [--env] Grant subscription', 'cyan');
|
|
39
39
|
log(' optima-logs <service> [--env] [--since] [--grep] [-n] View logs (cn=SLS 直连/aws=CloudWatch)', 'cyan');
|
|
@@ -41,7 +41,7 @@ switch (command) {
|
|
|
41
41
|
log(' /restart-ecs <service> [env] Restart ECS service (skill)', 'cyan');
|
|
42
42
|
|
|
43
43
|
log('\nSupported Services:', 'yellow');
|
|
44
|
-
log(' commerce-backend user-auth
|
|
44
|
+
log(' commerce-backend user-auth agentic-chat optima-logistics', 'cyan');
|
|
45
45
|
log(' session-gateway optima-scout billing browser-backend optima-generation', 'cyan');
|
|
46
46
|
|
|
47
47
|
log('\nEnvironments:', 'yellow');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
2
|
import { fetchInfisicalSecret } from './infisical-secrets';
|
|
3
3
|
import { getInfisicalConfig, getInfisicalToken, getCnInfisicalToken, getCnSecrets, resolveUserId } from './db-utils';
|
|
4
4
|
|
|
@@ -149,8 +149,17 @@ export function getServiceToken(env: string, scope?: string): string {
|
|
|
149
149
|
const effectiveScope = scope ?? (isCn ? CN_PROD_TOKEN_SCOPE : undefined);
|
|
150
150
|
const scopeParam = effectiveScope ? `&scope=${encodeURIComponent(effectiveScope)}` : '';
|
|
151
151
|
const body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}${scopeParam}`;
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
// execFileSync + 参数数组(不经 shell):Windows cmd.exe 不认单引号,shell 拼出的
|
|
153
|
+
// `-d '${body}'` 会被拆碎、curl 收到垃圾参数直接退出(#92)。数组传参绕开 shell、
|
|
154
|
+
// 跨平台一致;函数保持同步。
|
|
155
|
+
const response = execFileSync(
|
|
156
|
+
'curl',
|
|
157
|
+
[
|
|
158
|
+
'-s', '-X', 'POST',
|
|
159
|
+
`${authUrl}/api/v1/oauth/token`,
|
|
160
|
+
'-H', 'Content-Type: application/x-www-form-urlencoded',
|
|
161
|
+
'-d', body,
|
|
162
|
+
],
|
|
154
163
|
{ encoding: 'utf-8' },
|
|
155
164
|
);
|
|
156
165
|
|
package/bin/helpers/db-utils.ts
CHANGED
|
@@ -81,11 +81,14 @@ export function getInfisicalConfig(): InfisicalConfig {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export function getInfisicalToken(config: InfisicalConfig): string {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
// 复用 curlJson(execFileSync + 参数数组、不经 shell):Windows cmd.exe 不认单引号,
|
|
85
|
+
// 原来 shell 拼的 `-d '{...}'` JSON body 会被拆碎、curl 收垃圾参数直接退出(#92)。
|
|
86
|
+
// cn 路径早已用 curlJson,这里对齐;函数保持同步(调用方无需改 async)。
|
|
87
|
+
return curlJson([
|
|
88
|
+
'-X', 'POST', `${config.url}/api/v1/auth/universal-auth/login`,
|
|
89
|
+
'-H', 'Content-Type: application/json',
|
|
90
|
+
'-d', JSON.stringify({ clientId: config.clientId, clientSecret: config.clientSecret }),
|
|
91
|
+
]).accessToken;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
export function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record<string, string> {
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { callBilling, validateEnvCnProd } from '../billing-http';
|
|
2
2
|
import { confirmIfProd } from '../confirm-prompt';
|
|
3
3
|
import { resolveTargetUser } from '../grant-subscription';
|
|
4
|
+
import { operatorActorId } from '../operator';
|
|
4
5
|
|
|
5
6
|
interface GrantArgs {
|
|
6
7
|
identifier: string;
|
|
7
8
|
productKey: string;
|
|
8
9
|
justification: string;
|
|
9
10
|
yes: boolean;
|
|
11
|
+
operator?: string;
|
|
10
12
|
env: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -21,6 +23,7 @@ Required:
|
|
|
21
23
|
|
|
22
24
|
Optional:
|
|
23
25
|
--yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
|
|
26
|
+
--operator <name> Operator self-report for billing audit (default: local username)
|
|
24
27
|
--env stage|prod|cn-prod|cn-stage (default: stage)
|
|
25
28
|
|
|
26
29
|
Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
|
|
@@ -35,6 +38,7 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
|
|
|
35
38
|
case '--product-key': out.productKey = next; i++; break;
|
|
36
39
|
case '--justification': out.justification = next; i++; break;
|
|
37
40
|
case '--yes': out.yes = true; break;
|
|
41
|
+
case '--operator': out.operator = next; i++; break;
|
|
38
42
|
case '--env': out.env = next; i++; break;
|
|
39
43
|
default:
|
|
40
44
|
if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
|
|
@@ -67,6 +71,7 @@ export async function runGrant(argv: string[]): Promise<void> {
|
|
|
67
71
|
userId,
|
|
68
72
|
productKey: args.productKey,
|
|
69
73
|
justification: args.justification,
|
|
74
|
+
actorUserId: operatorActorId(args.operator ?? null),
|
|
70
75
|
});
|
|
71
76
|
console.log(`✓ Granted entitlement (HTTP ${res.status}):`);
|
|
72
77
|
console.log(JSON.stringify(res.body, null, 2));
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { callBilling, validateEnvCnProd } from '../billing-http';
|
|
2
2
|
import { confirmIfProd } from '../confirm-prompt';
|
|
3
3
|
import { resolveTargetUser } from '../grant-subscription';
|
|
4
|
+
import { operatorActorId } from '../operator';
|
|
4
5
|
|
|
5
6
|
interface RevokeArgs {
|
|
6
7
|
identifier: string;
|
|
7
8
|
productKey: string;
|
|
8
9
|
reason: string;
|
|
9
10
|
yes: boolean;
|
|
11
|
+
operator?: string;
|
|
10
12
|
env: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -28,6 +30,7 @@ Required:
|
|
|
28
30
|
|
|
29
31
|
Optional:
|
|
30
32
|
--yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
|
|
33
|
+
--operator <name> Operator self-report for billing audit (default: local username)
|
|
31
34
|
--env stage|prod|cn-prod|cn-stage (default: stage)
|
|
32
35
|
|
|
33
36
|
Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
|
|
@@ -43,6 +46,7 @@ error pointing to the right reversal flow.`);
|
|
|
43
46
|
case '--product-key': out.productKey = next; i++; break;
|
|
44
47
|
case '--reason': out.reason = next; i++; break;
|
|
45
48
|
case '--yes': out.yes = true; break;
|
|
49
|
+
case '--operator': out.operator = next; i++; break;
|
|
46
50
|
case '--env': out.env = next; i++; break;
|
|
47
51
|
default:
|
|
48
52
|
if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
|
|
@@ -111,6 +115,7 @@ export async function runRevoke(argv: string[]): Promise<void> {
|
|
|
111
115
|
entitlementId: target.id,
|
|
112
116
|
refundReason: args.reason,
|
|
113
117
|
refundAmountCents: 0,
|
|
118
|
+
actorUserId: operatorActorId(args.operator ?? null),
|
|
114
119
|
});
|
|
115
120
|
console.log(`✓ Revoked entitlement (HTTP ${res.status}):`);
|
|
116
121
|
console.log(JSON.stringify(res.body, null, 2));
|
|
@@ -88,6 +88,41 @@ async function httpRequest<T>(url: string, options: RequestInit = {}): Promise<T
|
|
|
88
88
|
return response.json() as Promise<T>;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* 判定「账号/商户已存在」——吃 httpRequest 抛出的 `HTTP <status>: <body>` message。
|
|
93
|
+
*
|
|
94
|
+
* 两条腿各自对应一个真实来源(user-auth / commerce-backend 是同一套代码跑在 AWS 与
|
|
95
|
+
* cn 两侧,响应形态一致;下列均为 2026-08-13 直查 origin/main 坐实):
|
|
96
|
+
*
|
|
97
|
+
* - **文案腿** `^HTTP 4xx` 且 `already (exists|registered)`
|
|
98
|
+
* · user-auth 注册已注册邮箱 → 400 "Email already registered"
|
|
99
|
+
* (user-auth `app/services/user.py`)。**这就是 #78 的病灶**:旧判定只认
|
|
100
|
+
* "already exists",措辞对不上 ⇒ cn 侧 `--email` 复用已有账号必挂。
|
|
101
|
+
* 实测 cn-prod 响应体:{"error":"Email already registered","status_code":400}
|
|
102
|
+
* · commerce-backend 重复建 merchant profile → 400 "Merchant profile already
|
|
103
|
+
* exists. Use PUT ..."(commerce-backend `src/api/merchants.py`)
|
|
104
|
+
* · 🔴 必须同时是 4xx:5xx 是服务端故障,body 里恰好带着这些词也不能吞 —— 否则
|
|
105
|
+
* `POST /api/merchants/me` 的 502/503 会被报成 ℹ already exists,一路打印到
|
|
106
|
+
* 「✅ Test token generated successfully!」+ Merchant ID: N/A、exit 0。
|
|
107
|
+
* - **状态码腿** `^HTTP 409`
|
|
108
|
+
* · 本工具只调 `POST /api/v1/auth/register/merchant` 与 `POST /api/merchants/me`
|
|
109
|
+
* 两个端点。**register/merchant 这条路径上唯一的 409** 是企业席位:
|
|
110
|
+
* account_type == ENTERPRISE_SEAT → 409 detail "email_bound_to_seat"。
|
|
111
|
+
* 🔴 **该文案既不含 exists 也不含 registered,所以这条腿不是历史遗留、
|
|
112
|
+
* 是席位场景的唯一命中路径,别删。**
|
|
113
|
+
* ⚠️ 「这条路径上唯一」的限定不是废话:user-auth 全仓有 20+ 处 409
|
|
114
|
+
* (referral / internal-sms / teams / admin),措辞各异且语义完全不同
|
|
115
|
+
* (如 `claim_in_progress` 是并发冲突)。给本工具接新端点时别想当然沿用本判定,
|
|
116
|
+
* 否则会把并发冲突静默吞成「已存在」。
|
|
117
|
+
*
|
|
118
|
+
* 锚 `^HTTP 409` 而不是 issue 建议的 `\b(409|400)\b`:后者会把 body 里夹带的数字当
|
|
119
|
+
* 状态码,把真故障(500 而 body 里提到 409)静默吞成「已存在」;而 400 本身太泛
|
|
120
|
+
* (参数校验失败也是 400),单凭它放行等于吞掉一切请求错误 —— 400 一律交给文案腿判。
|
|
121
|
+
*/
|
|
122
|
+
export function isAlreadyExistsError(message: string): boolean {
|
|
123
|
+
return /^HTTP 409\b/.test(message) || (/^HTTP 4\d\d\b/.test(message) && /already (exists|registered)/i.test(message));
|
|
124
|
+
}
|
|
125
|
+
|
|
91
126
|
async function registerMerchant(
|
|
92
127
|
email: string,
|
|
93
128
|
password: string,
|
|
@@ -112,7 +147,7 @@ async function registerMerchant(
|
|
|
112
147
|
console.log(`✓ Merchant registered successfully (ID: ${result.user_id ?? (result as any).id})`);
|
|
113
148
|
return result;
|
|
114
149
|
} catch (error: any) {
|
|
115
|
-
if (
|
|
150
|
+
if (isAlreadyExistsError(error.message)) {
|
|
116
151
|
console.log(`ℹ Merchant already exists, proceeding to login...`);
|
|
117
152
|
return { email, user_id: '', role: 'merchant', is_active: true, created_at: '', updated_at: '' };
|
|
118
153
|
}
|
|
@@ -167,7 +202,7 @@ async function setupMerchantProfile(token: string, businessName: string, config:
|
|
|
167
202
|
console.log(`✓ Merchant profile setup complete (ID: ${result.merchant_id})`);
|
|
168
203
|
return result;
|
|
169
204
|
} catch (error: any) {
|
|
170
|
-
if (
|
|
205
|
+
if (isAlreadyExistsError(error.message)) {
|
|
171
206
|
console.log(`ℹ Merchant profile already exists`);
|
|
172
207
|
return { merchant_id: '', business_name: businessName, user_id: '' };
|
|
173
208
|
}
|
|
@@ -311,4 +346,6 @@ Example:
|
|
|
311
346
|
}
|
|
312
347
|
}
|
|
313
348
|
|
|
314
|
-
main
|
|
349
|
+
if (require.main === module) {
|
|
350
|
+
main();
|
|
351
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { basename } from 'path';
|
|
11
11
|
import { randomUUID } from 'crypto';
|
|
12
12
|
import { callBilling, validateEnvCnProd } from './billing-http';
|
|
13
|
+
import { operatorActorId } from './operator';
|
|
13
14
|
import { resolveTargetUser } from './grant-subscription';
|
|
14
15
|
|
|
15
16
|
const CREDITS_PER_USD = 700;
|
|
@@ -19,6 +20,7 @@ interface Parsed {
|
|
|
19
20
|
credits: number | null;
|
|
20
21
|
amountUsd: number | null;
|
|
21
22
|
description: string | null;
|
|
23
|
+
operator: string | null;
|
|
22
24
|
env: string;
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -36,6 +38,7 @@ Options:
|
|
|
36
38
|
--credits <n> Credits to grant (integer >= 1). Primary unit.
|
|
37
39
|
--amount <usd> Alt: grant by USD ($1 = ${CREDITS_PER_USD} credits). Provide exactly one of --credits / --amount.
|
|
38
40
|
--description <text> Description for audit trail (optional)
|
|
41
|
+
--operator <name> Operator self-report for billing audit (default: local username)
|
|
39
42
|
--env <env> Environment: stage, prod, cn-prod, cn-stage (default: stage)
|
|
40
43
|
-h, --help Show this help
|
|
41
44
|
|
|
@@ -50,12 +53,14 @@ Examples:
|
|
|
50
53
|
let credits: number | null = null;
|
|
51
54
|
let amountUsd: number | null = null;
|
|
52
55
|
let description: string | null = null;
|
|
56
|
+
let operator: string | null = null;
|
|
53
57
|
let env = 'stage';
|
|
54
58
|
|
|
55
59
|
for (let i = 1; i < args.length; i++) {
|
|
56
60
|
if (args[i] === '--credits' && args[i + 1]) { credits = parseInt(args[++i], 10); }
|
|
57
61
|
else if (args[i] === '--amount' && args[i + 1]) { amountUsd = parseFloat(args[++i]); }
|
|
58
62
|
else if (args[i] === '--description' && args[i + 1]) { description = args[++i]; }
|
|
63
|
+
else if (args[i] === '--operator' && args[i + 1]) { operator = args[++i]; }
|
|
59
64
|
else if (args[i] === '--env' && args[i + 1]) { env = args[++i]; }
|
|
60
65
|
}
|
|
61
66
|
|
|
@@ -74,7 +79,7 @@ Examples:
|
|
|
74
79
|
}
|
|
75
80
|
validateEnvCnProd(env);
|
|
76
81
|
|
|
77
|
-
return { identifier, credits, amountUsd, description, env };
|
|
82
|
+
return { identifier, credits, amountUsd, description, operator, env };
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
async function main() {
|
|
@@ -83,7 +88,7 @@ async function main() {
|
|
|
83
88
|
console.warn('⚠️ optima-grant-balance 已更名为 optima-grant-credits(P15 钱包退役后回归积分)。请改用 `optima-grant-credits --credits <n>`;本别名仍可用,后续弃用。\n');
|
|
84
89
|
}
|
|
85
90
|
|
|
86
|
-
const { identifier, credits, amountUsd, description, env } = parseArgs(process.argv.slice(2));
|
|
91
|
+
const { identifier, credits, amountUsd, description, operator, env } = parseArgs(process.argv.slice(2));
|
|
87
92
|
|
|
88
93
|
const creditsDisplay = credits ?? Math.round((amountUsd as number) * CREDITS_PER_USD);
|
|
89
94
|
console.log(`\n🎁 Granting ${creditsDisplay} credits${amountUsd !== null ? ` ($${amountUsd.toFixed(2)})` : ''} to ${identifier} [${env.toUpperCase()}]\n`);
|
|
@@ -105,6 +110,7 @@ async function main() {
|
|
|
105
110
|
userId,
|
|
106
111
|
...amountField,
|
|
107
112
|
description: description ?? undefined,
|
|
113
|
+
actorUserId: operatorActorId(operator),
|
|
108
114
|
idempotencyKey: `dev-skills-grant:${randomUUID()}`,
|
|
109
115
|
},
|
|
110
116
|
);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getUserById,
|
|
12
12
|
validateEnvCnProd,
|
|
13
13
|
} from './billing-http';
|
|
14
|
+
import { operatorActorId } from './operator';
|
|
14
15
|
|
|
15
16
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
17
|
|
|
@@ -76,7 +77,7 @@ const PLANS_BY_ENV: Record<string, string[]> = {
|
|
|
76
77
|
'cn-stage': ['trial', 'starter', 'pro', 'enterprise', 'free'],
|
|
77
78
|
};
|
|
78
79
|
|
|
79
|
-
function parseArgs(args: string[]): { identifier: string; plan: string; months: number; env: string } {
|
|
80
|
+
function parseArgs(args: string[]): { identifier: string; plan: string; months: number; operator: string | null; env: string } {
|
|
80
81
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
81
82
|
console.log(`Usage: optima-grant-subscription <email|phone|userId> [options]
|
|
82
83
|
|
|
@@ -85,6 +86,7 @@ Options:
|
|
|
85
86
|
cn-prod/cn-stage additionally allow: free
|
|
86
87
|
(legacy *-cn ids are accepted and normalized to canonical)
|
|
87
88
|
--months <n> Duration in months (default: 1)
|
|
89
|
+
--operator <name> Operator self-report for billing audit (default: local username)
|
|
88
90
|
--env <env> Environment: stage, prod, cn-prod, cn-stage (default: stage)
|
|
89
91
|
-h, --help Show this help`);
|
|
90
92
|
process.exit(0);
|
|
@@ -93,11 +95,13 @@ Options:
|
|
|
93
95
|
const identifier = args[0];
|
|
94
96
|
let plan: string | null = null;
|
|
95
97
|
let months = 1;
|
|
98
|
+
let operator: string | null = null;
|
|
96
99
|
let env = 'stage';
|
|
97
100
|
|
|
98
101
|
for (let i = 1; i < args.length; i++) {
|
|
99
102
|
if (args[i] === '--plan' && args[i + 1]) { plan = args[++i]; }
|
|
100
103
|
else if (args[i] === '--months' && args[i + 1]) { months = parseInt(args[++i], 10); }
|
|
104
|
+
else if (args[i] === '--operator' && args[i + 1]) { operator = args[++i]; }
|
|
101
105
|
else if (args[i] === '--env' && args[i + 1]) { env = args[++i]; }
|
|
102
106
|
}
|
|
103
107
|
|
|
@@ -113,7 +117,7 @@ Options:
|
|
|
113
117
|
}
|
|
114
118
|
if (months < 1) { console.error('Months must be >= 1'); process.exit(1); }
|
|
115
119
|
|
|
116
|
-
return { identifier, plan, months, env };
|
|
120
|
+
return { identifier, plan, months, operator, env };
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
/**
|
|
@@ -181,7 +185,7 @@ export async function resolveTargetUser(
|
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
async function main() {
|
|
184
|
-
const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
|
|
188
|
+
const { identifier, plan, months, operator, env } = parseArgs(process.argv.slice(2));
|
|
185
189
|
|
|
186
190
|
console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${classifyIdentifier(identifier)}) for ${months} month(s) [${env.toUpperCase()}]\n`);
|
|
187
191
|
|
|
@@ -198,7 +202,7 @@ async function main() {
|
|
|
198
202
|
weeklyTokenLimit: number;
|
|
199
203
|
expiresAt: string;
|
|
200
204
|
warning?: string;
|
|
201
|
-
}>(env, 'POST', '/api/billing/admin/grant-subscription', { userId, planId: plan, months });
|
|
205
|
+
}>(env, 'POST', '/api/billing/admin/grant-subscription', { userId, planId: plan, months, actorUserId: operatorActorId(operator) });
|
|
202
206
|
|
|
203
207
|
console.log(`✓ Subscription ${body.subscriptionId} (${body.planId})`);
|
|
204
208
|
console.log(`✓ Credits: ${body.credits.toLocaleString()} (expires ${body.expiresAt})`);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { userInfo } from 'os';
|
|
2
|
+
|
|
3
|
+
// #429(Owner 拍板 3):CLI 发放族自报操作者——informational,billing 信任 allowlist client
|
|
4
|
+
// 自述(actor 不参与授权);不带 --operator 时回退本机用户名。缺省链最终兜底在 billing
|
|
5
|
+
// 侧(channel=client_id)。格式:dev-skills:<name>。
|
|
6
|
+
export function operatorActorId(operatorFlag?: string | null): string {
|
|
7
|
+
const name = (operatorFlag ?? '').trim() || userInfo().username;
|
|
8
|
+
return `dev-skills:${name}`;
|
|
9
|
+
}
|