@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.
- 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 +24 -0
- package/.claude/skills/yzsgo-e2e/SYNC.md +64 -2
- package/.claude/skills/yzsgo-e2e/chat_driver.py +1070 -98
- package/.claude/skills/yzsgo-e2e/pull_wire.py +39 -8
- package/.claude/skills/yzsgo-e2e/run_e2e.py +24 -3
- 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 +24 -0
- package/.codex/skills/yzsgo-e2e/SYNC.md +64 -2
- package/.codex/skills/yzsgo-e2e/chat_driver.py +1070 -98
- package/.codex/skills/yzsgo-e2e/pull_wire.py +39 -8
- package/.codex/skills/yzsgo-e2e/run_e2e.py +24 -3
- package/AGENTS.md +1 -1
- package/bin/cli.js +3 -3
- package/bin/helpers/billing-http.ts +12 -3
- package/bin/helpers/cn-deploy.ts +2 -1
- package/bin/helpers/db-utils.ts +8 -5
- package/bin/helpers/generate-test-token.ts +40 -3
- package/bin/helpers/verify-health.ts +77 -29
- package/dist/bin/helpers/billing-http.js +9 -1
- package/dist/bin/helpers/cn-deploy.js +2 -1
- package/dist/bin/helpers/db-utils.js +8 -2
- package/dist/bin/helpers/generate-test-token.js +40 -3
- 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,21 @@ def main():
|
|
|
85
85
|
|
|
86
86
|
started_ts = _utc_now()
|
|
87
87
|
answers = _parse_answers(args.answer)
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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/cn-deploy.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
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> {
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
33
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
'
|
|
45
|
-
'
|
|
46
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|