@optima-chat/dev-skills 0.16.7 → 0.16.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.claude/commands/logs.md +1 -1
  2. package/.claude/commands/query-db.md +6 -1
  3. package/.claude/skills/discount-codes/SKILL.md +4 -4
  4. package/.claude/skills/generate-test-token/SKILL.md +8 -4
  5. package/.claude/skills/logs/SKILL.md +5 -3
  6. package/.claude/skills/show-env/SKILL.md +1 -1
  7. package/.claude/skills/yzsgo-e2e/SKILL.md +24 -0
  8. package/.claude/skills/yzsgo-e2e/SYNC.md +64 -2
  9. package/.claude/skills/yzsgo-e2e/chat_driver.py +1070 -98
  10. package/.claude/skills/yzsgo-e2e/pull_wire.py +39 -8
  11. package/.claude/skills/yzsgo-e2e/run_e2e.py +24 -3
  12. package/.codex/skills/generate-test-token/SKILL.md +3 -2
  13. package/.codex/skills/logs/SKILL.md +4 -2
  14. package/.codex/skills/show-env/SKILL.md +4 -2
  15. package/.codex/skills/yzsgo-e2e/SKILL.md +24 -0
  16. package/.codex/skills/yzsgo-e2e/SYNC.md +64 -2
  17. package/.codex/skills/yzsgo-e2e/chat_driver.py +1070 -98
  18. package/.codex/skills/yzsgo-e2e/pull_wire.py +39 -8
  19. package/.codex/skills/yzsgo-e2e/run_e2e.py +24 -3
  20. package/AGENTS.md +1 -1
  21. package/bin/cli.js +3 -3
  22. package/bin/helpers/billing-http.ts +12 -3
  23. package/bin/helpers/cn-deploy.ts +2 -1
  24. package/bin/helpers/db-utils.ts +8 -5
  25. package/bin/helpers/generate-test-token.ts +40 -3
  26. package/bin/helpers/verify-health.ts +77 -29
  27. package/dist/bin/helpers/billing-http.js +9 -1
  28. package/dist/bin/helpers/cn-deploy.js +2 -1
  29. package/dist/bin/helpers/db-utils.js +8 -2
  30. package/dist/bin/helpers/generate-test-token.js +40 -3
  31. package/dist/bin/helpers/verify-health.js +83 -27
  32. 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
- L.append(render_block(b, deref))
194
- return prompt, ts0, len(errs), len(dangling), "\n".join(L)
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,21 @@ def main():
85
85
 
86
86
  started_ts = _utc_now()
87
87
  answers = _parse_answers(args.answer)
88
- d = chat_driver.ChatDriver().attach()
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 定位退回启发式。
93
+ # ziniao 是 attach() 的必填关键字参数(上游 store-skills #611,漏传即 TypeError):
94
+ # 本 skill 测的是鸭嘴兽对话链路,这个 tab 不绑定任何紫鸟 profile,所以显式声明 None + 理由。
95
+ # 若 --message 正文里写了 `--ziniao-profile <id>`,send() 会抛 ZiniaoDeclarationConflict
96
+ # 拒发 —— 那是上游有意的闸,不是本脚本的 bug。装到 ~/.claude 后已知 profile 表为空
97
+ # (它读 store-skills 仓库里的 e2e/registry*.yaml),所以只认 `--ziniao-profile` 这种显式写法。
98
+ d = chat_driver.ChatDriver().attach(
99
+ ziniao=None, reason="yzsgo-e2e 驱动鸭嘴兽网页对话做端到端测试,本脚本不绑定任何紫鸟 profile")
100
+ session_id = d.session_id # attach 后立刻取:放在 try 里的话,中途抛异常会留下未绑定名
101
+ if not d.tab_isolated:
102
+ print("[warn] 未能独占 tab(该环境 multi-tab 未开)—— wire 定位退回 (时间,首句) 启发式")
89
103
  try:
90
104
  d.new_conversation()
91
105
  turns = []
@@ -99,13 +113,17 @@ def main():
99
113
  wire_root = pull_wire.pull(args.user, since_days=args.since, out=args.out)
100
114
  meta = {"env": args.env, "started_ts": started_ts, "first_message": args.message[0],
101
115
  "expect": args.expect, "issue_repo": args.issue_repo, "turns": turns,
116
+ "session_id": session_id, # 本轮跑在哪个 gateway session(= wire 目录名)
102
117
  "located": None, "located_reason": None}
103
118
  wrote_prepped = False
104
119
  if not wire_root:
105
120
  meta["located_reason"] = "no_wire_session"
106
121
  if wire_root:
107
122
  index = pull_wire.emit_conversation_index(wire_root)
108
- hit = pull_wire.locate_conversation(index, started_ts, args.message[0])
123
+ hit = pull_wire.locate_conversation(index, started_ts, args.message[0],
124
+ session_id=session_id)
125
+ if session_id and not hit:
126
+ meta["located_reason"] = f"session {session_id} 在 wire 索引里没有对话(未落盘/TTL 过期/账号不对)"
109
127
  meta["located"] = hit
110
128
  if hit:
111
129
  sdir = os.path.join(wire_root, hit["sid"])
@@ -117,7 +135,10 @@ def main():
117
135
  convs = pull_wire.segment(reqs, deref)
118
136
  conv = select_conversation_in_session(convs, deref, hit)
119
137
  if conv is not None:
120
- _, _, _, _, wire_md = pull_wire.render_conversation(conv, resps, deref, hit["gidx"])
138
+ # 6 元返回(上游 #102 起多了 resp_ok):末 response 不正常时 wire_md 结尾已显式收口,
139
+ # 但 meta 也记一份,报告层不必去 grep transcript 才知道「最终回复不可核」。
140
+ _, _, _, _, resp_ok, wire_md = pull_wire.render_conversation(conv, resps, deref, hit["gidx"])
141
+ meta["wire_last_response_ok"] = resp_ok
121
142
  prepped = prep_conversation.merge_browser_evidence(wire_md, turns)
122
143
  with open(os.path.join(args.out, "prepped.md"), "w", encoding="utf-8") as f:
123
144
  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 cn|prod|all] Probe L1-L5 上线健康', 'cyan');
36
- log(' optima-generate-test-token [--env production] Generate test token', 'cyan');
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 mcp-host agentic-chat optima-logistics', 'cyan');
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 { execSync } from 'child_process';
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
- const response = execSync(
153
- `curl -s -X POST '${authUrl}/api/v1/oauth/token' -H 'Content-Type: application/x-www-form-urlencoded' -d '${body}'`,
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
 
@@ -142,7 +142,8 @@ async function main() {
142
142
 
143
143
  // 2. 触发(凭证由云效变量组供给,无需注入)。stage/prod 流水线 id 均按名 `${svc}-cn-${env}`
144
144
  // 从云效实时解析、不硬编码,根除与 optima-terraform cn-run.py 手工 PIPELINES 表漂移(#84)。
145
- const lp = devops('ListPipelines', { maxResults: '100' });
145
+ // nextToken 2026-09 起被 aliyun CLI 标成必填,不传则 API 直接返参数错误 → devops() 吞成 {} 误报「云效无流水线」。
146
+ const lp = devops('ListPipelines', { maxResults: '100', nextToken: '0' });
146
147
  const hit = (lp.pipelines || []).find((p: any) => p.pipelineName === `${svcName}-cn-${envName}`);
147
148
  if (!hit) { console.error(`✗ 云效无 ${svcName}-cn-${envName} 流水线`); process.exit(1); }
148
149
  const pipelineId = hit.pipelineId;
@@ -81,11 +81,14 @@ export function getInfisicalConfig(): InfisicalConfig {
81
81
  }
82
82
 
83
83
  export function getInfisicalToken(config: InfisicalConfig): string {
84
- const response = execSync(
85
- `curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`,
86
- { encoding: 'utf-8' }
87
- );
88
- return JSON.parse(response).accessToken;
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> {
@@ -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 (error.message.includes('409') || error.message.includes('already exists')) {
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 (error.message.includes('409') || error.message.includes('already exists')) {
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
+ }
@@ -17,8 +17,8 @@
17
17
  *
18
18
  * 用法:
19
19
  * optima-verify-health user-auth # 默认 cn-prod
20
- * optima-verify-health user-auth --env prod # 环境 stage|prod|cn|all
21
- * optima-verify-health --all --env all # stage/prod/cn 三环境矩阵
20
+ * optima-verify-health user-auth --env prod # 环境 stage|prod|cn-prod|cn-stage|all
21
+ * optima-verify-health --all --env all # stage/prod/cn-prod/cn-stage 四环境矩阵
22
22
  * optima-verify-health gateway-core --expect-commit a1b2c3d
23
23
  * optima-verify-health --url https://auth.yzsgo.com/health
24
24
  * optima-verify-health --all --json # 机器可读,接 CI
@@ -29,23 +29,40 @@ import { promises as dns } from 'node:dns';
29
29
  import * as tls from 'node:tls';
30
30
  import * as https from 'node:https';
31
31
 
32
- type Env = 'stage' | 'prod' | 'cn' | 'cn-stage';
33
- interface SvcCfg { path: string; stage?: string; prod?: string; cn?: string; cn_path?: string; 'cn-stage'?: string; 'cn-stage_path'?: string; }
32
+ // 环境名与全仓保持一致(logs.ts CN_ENVS、query-db.ts VALID_ENVS、show-env、
33
+ // billing-http validateEnvCnProd):阿里云生产叫 cn-prod,不叫 cn
34
+ // 🔴 名字对不上时 resolve() 会把每个 SvcCfg 键查成 undefined,于是走到「无目标」分支,
35
+ // 把「环境名打错」报成「该服务无部署」—— 与 #83 那批 301 被报成「疑似未部署」同一个病:
36
+ // 探针在不确定的时候撒了一个确定的谎。
37
+ type Env = 'stage' | 'prod' | 'cn-prod' | 'cn-stage';
38
+ interface SvcCfg { path: string; stage?: string; prod?: string; 'cn-prod'?: string; 'cn-prod_path'?: string; 'cn-stage'?: string; 'cn-stage_path'?: string; }
34
39
 
35
40
  // 服务 × 环境 FQDN 表。某服务某环境没部署 → 该 env 键缺省,探时跳过。
36
41
  // cn-prod 真实 subdomain 抄自 optima-terraform alicloud/stacks/cn-prod-ingress-sae/main.tf。
37
42
  // #201 (2026-06-12): yzsgo.com 全量迁移完成,旧 *-cn.optima.chat 路由已下线。
38
43
  // cn-stage(阿里云预发)域名 *.stage.optima.chat,抄自 cn-stage-ingress-sae services map。
44
+ // #83 (2026-08-13): AWS stage 是 *.stage.optima.onl(点号),旧的连字符形式 *-stage.optima.onl
45
+ // 已不再路由到服务——三项实测 301 → www.optima.onl,探测恒红并误报成「疑似未部署」。
46
+ // 🔴 别只看 stage 列:agentic-chat 的 prod 也踩同一个坑(见下面那行的注释)。
39
47
  const SERVICES: Record<string, SvcCfg> = {
40
- 'user-auth': { path: '/health', stage: 'auth-stage.optima.onl', prod: 'auth.optima.onl', cn: 'auth.yzsgo.com', 'cn-stage': 'auth.stage.optima.chat' },
41
- 'agentic-chat': { path: '/api/health', stage: 'ai-stage.optima.onl', prod: 'ai.optima.onl', cn: 'app.yzsgo.com', 'cn-stage': 'app.stage.optima.chat' },
42
- 'commerce-backend': { path: '/health', stage: 'api-stage.optima.onl', prod: 'api.optima.onl', cn: 'commerce.yzsgo.com', cn_path: '/health/live', 'cn-stage': 'commerce.stage.optima.chat', 'cn-stage_path': '/health/live' },
43
- 'mcp-host': { path: '/health', stage: 'mcp-stage.optima.onl', prod: 'mcp.optima.onl' },
44
- 'gateway-core': { path: '/health', cn: 'gw.yzsgo.com', 'cn-stage': 'gw.stage.optima.chat' },
45
- 'optima-scout': { path: '/health', cn: 'scout.yzsgo.com', 'cn-stage': 'scout.stage.optima.chat' },
46
- 'optima-skills': { path: '/health', cn: 'skills.yzsgo.com', 'cn-stage': 'skills.stage.optima.chat' },
48
+ 'user-auth': { path: '/health', stage: 'auth.stage.optima.onl', prod: 'auth.optima.onl', 'cn-prod': 'auth.yzsgo.com', 'cn-stage': 'auth.stage.optima.chat' },
49
+ // agentic-chat prod 入口是 www 不是 ai:prod-ecs/variables.tf 里它的 subdomain = "www",
50
+ // main.tf 有一条 ai_to_www_redirect(priority 309) 专门把 ai.optima.onl 301 www。
51
+ // 实测 ai.optima.onl/api/health → 301;www.optima.onl/api/health → 200 service=agentic-chat。
52
+ 'agentic-chat': { path: '/api/health', stage: 'ai.stage.optima.onl', prod: 'www.optima.onl', 'cn-prod': 'app.yzsgo.com', 'cn-stage': 'app.stage.optima.chat' },
53
+ 'commerce-backend': { path: '/health', stage: 'api.stage.optima.onl', prod: 'api.optima.onl', 'cn-prod': 'commerce.yzsgo.com', 'cn-prod_path': '/health/live', 'cn-stage': 'commerce.stage.optima.chat', 'cn-stage_path': '/health/live' },
54
+ // #83: mcp-host 已于 2025-12-18 下线,故不在表内 —— optima-terraform origin/main(f97adbb) 的
55
+ // stage-ecs/variables.tf:146 与 prod-ecs/variables.tf:508 都写着「MCP 工具服务已移除」,
56
+ // 两个 ecs stack 的 services map 里均无该条目;实测四个候选主机名全是壳(mcp.stage.optima.onl
57
+ // 307 → /en-US/health 前端 locale 路由,mcp-stage.optima.onl 与 prod 的 mcp.optima.onl 均
58
+ // 301 → www)。🔴 别再把它加回来:worstOk 是全局单标志(见 main()),留着它会让 `--all --env stage`
59
+ // 与 `--all --env prod` 恒 exit 1,而那个退出码正是接 CI 卡口时唯一被读的东西。
60
+ // 同一约束在 tests/service-matrix-alignment.test.js 里对 show-env 的清单也钉着。
61
+ 'gateway-core': { path: '/health', 'cn-prod': 'gw.yzsgo.com', 'cn-stage': 'gw.stage.optima.chat' },
62
+ 'optima-scout': { path: '/health', 'cn-prod': 'scout.yzsgo.com', 'cn-stage': 'scout.stage.optima.chat' },
63
+ 'optima-skills': { path: '/health', 'cn-prod': 'skills.yzsgo.com', 'cn-stage': 'skills.stage.optima.chat' },
47
64
  };
48
- const ENVS: Env[] = ['stage', 'prod', 'cn', 'cn-stage'];
65
+ const ENVS: Env[] = ['stage', 'prod', 'cn-prod', 'cn-stage'];
49
66
 
50
67
  const G = '\x1b[32m', R = '\x1b[31m', Y = '\x1b[33m', B = '\x1b[34m', N = '\x1b[0m';
51
68
  const MARK: Record<string, string> = { ok: `${G}✅${N}`, fail: `${R}❌${N}`, warn: `${Y}⚠️ ${N}`, na: `${B}··${N}` };
@@ -165,27 +182,56 @@ function resolve(svc: string, e: Env): [string, string, string] | null {
165
182
  return [`${svc} [${e}]`, host, path];
166
183
  }
167
184
 
185
+ export interface Args { help: boolean; json: boolean; strict: boolean; all: boolean; env: Env | 'all'; expect?: string; url?: string; service?: string; }
186
+
187
+ // 🔴 不认识的输入一律报错、绝不静默丢弃(同 query-db.ts 的 parseQueryDbArgs、logs.ts 的 parseArgs):
188
+ // 此前 `--env=stage` 不认、`--env` 漏写取值、多敲一个位置参数都会被静默无视而回落默认 cn-prod ——
189
+ // 想探 stage,实际探的是阿里云生产,还 exit 0。
190
+ export function parseArgs(argv: string[]): Args {
191
+ const flags = new Set<string>(), vals: Record<string, string> = {}, positional: string[] = [];
192
+ for (let i = 0; i < argv.length; i++) {
193
+ const arg = argv[i];
194
+ const eq = arg.startsWith('--') ? arg.indexOf('=') : -1;
195
+ const name = eq > 0 ? arg.slice(0, eq) : arg;
196
+ if (['--env', '--expect-commit', '--url'].includes(name)) {
197
+ const v = eq > 0 ? arg.slice(eq + 1) : argv[++i];
198
+ if (!v || v.startsWith('-')) throw new Error(`${name} 缺少取值`);
199
+ vals[name] = v;
200
+ } else if (['--json', '--strict', '--all', '--help', '-h'].includes(arg)) flags.add(arg);
201
+ else if (arg.startsWith('-')) throw new Error(`未知参数:${arg}`);
202
+ else positional.push(arg);
203
+ }
204
+ // 🔴 必须先校验再转型:不校验的话任何不认识的 env 都会让 resolve() 把每个 SvcCfg 键查成
205
+ // undefined,于是落到下面的「无目标」分支,把「环境名打错」报成「该服务无部署」——
206
+ // 一个活着的服务被说成没上线,正是 #83 要根治的那类静默误报。
207
+ let envArg = vals['--env'] || 'cn-prod';
208
+ // 'cn' 是本文件的历史叫法,继续放行但不在 usage 里宣传(同 query-db.ts 的 ENV_ALIASES)。
209
+ if (envArg === 'cn') envArg = 'cn-prod';
210
+ if (envArg !== 'all' && !ENVS.includes(envArg as Env)) throw new Error(`未知环境:${envArg}(可选:${ENVS.join(' | ')} | all)`);
211
+ if (positional.length > 1) throw new Error(`多余参数:${positional.slice(1).join(' ')}(一次探一个服务;环境用 --env 指定)`);
212
+ const service = positional[0];
213
+ if (service && !Object.keys(SERVICES).includes(service)) throw new Error(`未知服务:${service}(可选:${Object.keys(SERVICES).join(' | ')})`);
214
+ const url = vals['--url'], all = flags.has('--all');
215
+ return { help: flags.has('--help') || flags.has('-h') || !(url || all || service), json: flags.has('--json'), strict: flags.has('--strict'), all, env: envArg as Env | 'all', expect: vals['--expect-commit'], url, service };
216
+ }
217
+
168
218
  async function main() {
169
- const argv = process.argv.slice(2);
170
- const has = (f: string) => argv.includes(f);
171
- const val = (f: string) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
172
- const asJson = has('--json'), strict = has('--strict');
173
- const expect = val('--expect-commit');
174
- const env = (val('--env') || 'cn') as Env | 'all';
175
- const envs: Env[] = env === 'all' ? ENVS : [env as Env];
176
- const positional = argv.filter((x, i) => !x.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--') && ['--env', '--expect-commit', '--url'].includes(argv[i - 1])));
219
+ let args: Args;
220
+ try { args = parseArgs(process.argv.slice(2)); } catch (e: any) { console.error(`❌ ${e.message}`); process.exit(2); }
221
+ const { json: asJson, strict, expect, env } = args;
222
+ const envs: Env[] = env === 'all' ? ENVS : [env];
177
223
 
178
224
  let targets: [string, string, string][] = [];
179
- if (has('--url')) {
180
- const u = new URL(val('--url')!);
181
- targets = [[u.hostname, u.hostname, u.pathname || '/health']];
182
- } else if (has('--all')) {
183
- for (const e of envs) for (const s of Object.keys(SERVICES)) { const t = resolve(s, e); if (t) targets.push(t); }
184
- } else if (positional[0] && SERVICES[positional[0]]) {
185
- for (const e of envs) { const t = resolve(positional[0], e); if (t) targets.push(t); }
186
- } else {
225
+ if (args.help) {
187
226
  console.log((require('fs').readFileSync(__filename, 'utf-8').match(/\/\*\*[\s\S]*?\*\//)?.[0] || '').replace(/^\s*\*?/gm, ''));
188
227
  process.exit(2);
228
+ } else if (args.url) {
229
+ const u = new URL(args.url);
230
+ targets = [[u.hostname, u.hostname, u.pathname || '/health']];
231
+ } else if (args.all) {
232
+ for (const e of envs) for (const s of Object.keys(SERVICES)) { const t = resolve(s, e); if (t) targets.push(t); }
233
+ } else if (args.service) {
234
+ for (const e of envs) { const t = resolve(args.service, e); if (t) targets.push(t); }
189
235
  }
190
236
  if (targets.length === 0) { console.log(`(无目标:${env} 环境下该服务无部署)`); process.exit(2); }
191
237
 
@@ -207,4 +253,6 @@ async function main() {
207
253
  process.exitCode = worstOk ? 0 : 1;
208
254
  }
209
255
 
210
- main();
256
+ if (require.main === module) {
257
+ main();
258
+ }
@@ -156,7 +156,15 @@ function getServiceToken(env, scope) {
156
156
  const effectiveScope = scope ?? (isCn ? CN_PROD_TOKEN_SCOPE : undefined);
157
157
  const scopeParam = effectiveScope ? `&scope=${encodeURIComponent(effectiveScope)}` : '';
158
158
  const body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}${scopeParam}`;
159
- const response = (0, child_process_1.execSync)(`curl -s -X POST '${authUrl}/api/v1/oauth/token' -H 'Content-Type: application/x-www-form-urlencoded' -d '${body}'`, { encoding: 'utf-8' });
159
+ // execFileSync + 参数数组(不经 shell):Windows cmd.exe 不认单引号,shell 拼出的
160
+ // `-d '${body}'` 会被拆碎、curl 收到垃圾参数直接退出(#92)。数组传参绕开 shell、
161
+ // 跨平台一致;函数保持同步。
162
+ const response = (0, child_process_1.execFileSync)('curl', [
163
+ '-s', '-X', 'POST',
164
+ `${authUrl}/api/v1/oauth/token`,
165
+ '-H', 'Content-Type: application/x-www-form-urlencoded',
166
+ '-d', body,
167
+ ], { encoding: 'utf-8' });
160
168
  let parsed;
161
169
  try {
162
170
  parsed = JSON.parse(response);
@@ -163,7 +163,8 @@ async function main() {
163
163
  console.log(`✓ mirror 已追平 ${svc.repo}@${ref} = ${ghSha.slice(0, 10)}`);
164
164
  // 2. 触发(凭证由云效变量组供给,无需注入)。stage/prod 流水线 id 均按名 `${svc}-cn-${env}`
165
165
  // 从云效实时解析、不硬编码,根除与 optima-terraform cn-run.py 手工 PIPELINES 表漂移(#84)。
166
- const lp = devops('ListPipelines', { maxResults: '100' });
166
+ // nextToken 2026-09 起被 aliyun CLI 标成必填,不传则 API 直接返参数错误 → devops() 吞成 {} 误报「云效无流水线」。
167
+ const lp = devops('ListPipelines', { maxResults: '100', nextToken: '0' });
167
168
  const hit = (lp.pipelines || []).find((p) => p.pipelineName === `${svcName}-cn-${envName}`);
168
169
  if (!hit) {
169
170
  console.error(`✗ 云效无 ${svcName}-cn-${envName} 流水线`);
@@ -117,8 +117,14 @@ function getInfisicalConfig() {
117
117
  };
118
118
  }
119
119
  function getInfisicalToken(config) {
120
- const response = (0, child_process_1.execSync)(`curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`, { encoding: 'utf-8' });
121
- return JSON.parse(response).accessToken;
120
+ // 复用 curlJson(execFileSync + 参数数组、不经 shell):Windows cmd.exe 不认单引号,
121
+ // 原来 shell 拼的 `-d '{...}'` JSON body 会被拆碎、curl 收垃圾参数直接退出(#92)。
122
+ // cn 路径早已用 curlJson,这里对齐;函数保持同步(调用方无需改 async)。
123
+ return curlJson([
124
+ '-X', 'POST', `${config.url}/api/v1/auth/universal-auth/login`,
125
+ '-H', 'Content-Type: application/json',
126
+ '-d', JSON.stringify({ clientId: config.clientId, clientSecret: config.clientSecret }),
127
+ ]).accessToken;
122
128
  }
123
129
  function getInfisicalSecrets(config, token, environment, secretPath) {
124
130
  const response = (0, child_process_1.execSync)(`curl -s "${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${secretPath}" -H "Authorization: Bearer ${token}"`, { encoding: 'utf-8' });
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  };
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.isAlreadyExistsError = isAlreadyExistsError;
37
38
  const fs = __importStar(require("fs"));
38
39
  const os = __importStar(require("os"));
39
40
  const path = __importStar(require("path"));
@@ -88,6 +89,40 @@ async function httpRequest(url, options = {}) {
88
89
  }
89
90
  return response.json();
90
91
  }
92
+ /**
93
+ * 判定「账号/商户已存在」——吃 httpRequest 抛出的 `HTTP <status>: <body>` message。
94
+ *
95
+ * 两条腿各自对应一个真实来源(user-auth / commerce-backend 是同一套代码跑在 AWS 与
96
+ * cn 两侧,响应形态一致;下列均为 2026-08-13 直查 origin/main 坐实):
97
+ *
98
+ * - **文案腿** `^HTTP 4xx` 且 `already (exists|registered)`
99
+ * · user-auth 注册已注册邮箱 → 400 "Email already registered"
100
+ * (user-auth `app/services/user.py`)。**这就是 #78 的病灶**:旧判定只认
101
+ * "already exists",措辞对不上 ⇒ cn 侧 `--email` 复用已有账号必挂。
102
+ * 实测 cn-prod 响应体:{"error":"Email already registered","status_code":400}
103
+ * · commerce-backend 重复建 merchant profile → 400 "Merchant profile already
104
+ * exists. Use PUT ..."(commerce-backend `src/api/merchants.py`)
105
+ * · 🔴 必须同时是 4xx:5xx 是服务端故障,body 里恰好带着这些词也不能吞 —— 否则
106
+ * `POST /api/merchants/me` 的 502/503 会被报成 ℹ already exists,一路打印到
107
+ * 「✅ Test token generated successfully!」+ Merchant ID: N/A、exit 0。
108
+ * - **状态码腿** `^HTTP 409`
109
+ * · 本工具只调 `POST /api/v1/auth/register/merchant` 与 `POST /api/merchants/me`
110
+ * 两个端点。**register/merchant 这条路径上唯一的 409** 是企业席位:
111
+ * account_type == ENTERPRISE_SEAT → 409 detail "email_bound_to_seat"。
112
+ * 🔴 **该文案既不含 exists 也不含 registered,所以这条腿不是历史遗留、
113
+ * 是席位场景的唯一命中路径,别删。**
114
+ * ⚠️ 「这条路径上唯一」的限定不是废话:user-auth 全仓有 20+ 处 409
115
+ * (referral / internal-sms / teams / admin),措辞各异且语义完全不同
116
+ * (如 `claim_in_progress` 是并发冲突)。给本工具接新端点时别想当然沿用本判定,
117
+ * 否则会把并发冲突静默吞成「已存在」。
118
+ *
119
+ * 锚 `^HTTP 409` 而不是 issue 建议的 `\b(409|400)\b`:后者会把 body 里夹带的数字当
120
+ * 状态码,把真故障(500 而 body 里提到 409)静默吞成「已存在」;而 400 本身太泛
121
+ * (参数校验失败也是 400),单凭它放行等于吞掉一切请求错误 —— 400 一律交给文案腿判。
122
+ */
123
+ function isAlreadyExistsError(message) {
124
+ return /^HTTP 409\b/.test(message) || (/^HTTP 4\d\d\b/.test(message) && /already (exists|registered)/i.test(message));
125
+ }
91
126
  async function registerMerchant(email, password, businessName, config, phone, address) {
92
127
  console.log(`\n📝 Registering merchant: ${email}...`);
93
128
  const payload = { email, password, business_name: businessName };
@@ -105,7 +140,7 @@ async function registerMerchant(email, password, businessName, config, phone, ad
105
140
  return result;
106
141
  }
107
142
  catch (error) {
108
- if (error.message.includes('409') || error.message.includes('already exists')) {
143
+ if (isAlreadyExistsError(error.message)) {
109
144
  console.log(`ℹ Merchant already exists, proceeding to login...`);
110
145
  return { email, user_id: '', role: 'merchant', is_active: true, created_at: '', updated_at: '' };
111
146
  }
@@ -153,7 +188,7 @@ async function setupMerchantProfile(token, businessName, config) {
153
188
  return result;
154
189
  }
155
190
  catch (error) {
156
- if (error.message.includes('409') || error.message.includes('already exists')) {
191
+ if (isAlreadyExistsError(error.message)) {
157
192
  console.log(`ℹ Merchant profile already exists`);
158
193
  return { merchant_id: '', business_name: businessName, user_id: '' };
159
194
  }
@@ -292,4 +327,6 @@ Example:
292
327
  process.exit(1);
293
328
  }
294
329
  }
295
- main();
330
+ if (require.main === module) {
331
+ main();
332
+ }