@danceiny/gotry 0.0.1-rc.13 → 0.0.1-rc.14
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/README.md +119 -140
- package/README.zh-CN.md +233 -0
- package/bin/gotry-bootstrap.js +126 -0
- package/bin/gotry-inner.js +10 -3
- package/dist/capabilities/hbcli.js +42 -15
- package/dist/scripts/bootstrap-tests.js +56 -0
- package/dist/scripts/flyai-tests.js +95 -0
- package/dist/scripts/hbcli-tests.js +28 -3
- package/dist/src/index.js +8 -3
- package/package.json +2 -1
- package/ts/capabilities/hbcli.ts +51 -19
- package/ts/src/index.ts +17 -5
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gotry 外部依赖自举(founder 2026-08-29 指令:安装 gotry 时按上游本身的方式装好外部依赖):
|
|
4
|
+
* hbcli(hotelbyte-cli) → 官方 install.sh(原生二进制,~/.local/bin/hbcli)
|
|
5
|
+
* agent-reach → 官方 pip 安装 git+ upstream(包内 .venv,与 z3-solver 同址原则)
|
|
6
|
+
* flyai → 无需安装(npx 每次自拉 @fly-ai/flyai-cli)
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* node bin/gotry-bootstrap.js # 显式安装(缺啥装啥;失败 exit 1)
|
|
10
|
+
* node bin/gotry-bootstrap.js --auto # postinstall 模式:CI/跳过开关检测,任何失败不挡安装(exit 0)
|
|
11
|
+
* node bin/gotry-bootstrap.js --check-only # 只探测报告,不安装(测试钩子)
|
|
12
|
+
*
|
|
13
|
+
* 环境开关:
|
|
14
|
+
* GOTRY_SETUP_SKIP=1 全部跳过
|
|
15
|
+
* GOTRY_SETUP_HBCLI=0 跳过 hbcli
|
|
16
|
+
* GOTRY_SETUP_REACH=0 跳过 agent-reach
|
|
17
|
+
*
|
|
18
|
+
* 契约:安装外部依赖永远不挡 gotry 本体——能力层各有降级路径(静态包/not-installed
|
|
19
|
+
* verdict),自举失败只降级体验,不产生故障。凭证(hbcli auth / agent-reach 渠道
|
|
20
|
+
* cookie)属用户资产,不自动配置,装完二进制后给指引。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from 'node:child_process'
|
|
24
|
+
import { existsSync } from 'node:fs'
|
|
25
|
+
import { homedir } from 'node:os'
|
|
26
|
+
import { join, dirname } from 'node:path'
|
|
27
|
+
import { fileURLToPath } from 'node:url'
|
|
28
|
+
|
|
29
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
30
|
+
const AUTO = process.argv.includes('--auto')
|
|
31
|
+
const CHECK_ONLY = process.argv.includes('--check-only')
|
|
32
|
+
|
|
33
|
+
const HBCLI_INSTALL_CMD = 'curl -fsSL https://github.com/hotelbyte-com/docs/releases/latest/download/install.sh | bash'
|
|
34
|
+
const REACH_INSTALL_URL = 'git+https://github.com/Panniantong/Agent-Reach.git'
|
|
35
|
+
|
|
36
|
+
/** 带超时的子进程(inherit stdio 让用户看见上游安装进度) */
|
|
37
|
+
function run(cmd, args, { timeoutMs, cwd } = {}) {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const child = spawn(cmd, args, { stdio: 'inherit', cwd, env: process.env })
|
|
40
|
+
let done = false
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
if (!done) { done = true; try { child.kill('SIGKILL') } catch { /* ignore */ } }
|
|
43
|
+
}, timeoutMs)
|
|
44
|
+
child.on('error', (e) => { if (!done) { done = true; clearTimeout(timer); resolve({ ok: false, error: e.message }) } })
|
|
45
|
+
child.on('exit', (code) => { if (!done) { done = true; clearTimeout(timer); resolve({ ok: code === 0, error: code === 0 ? undefined : `exit ${code}` }) } })
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 静默探测命令是否可执行(带超时) */
|
|
50
|
+
function probe(cmd, args, timeoutMs = 10_000) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const child = spawn(cmd, args, { stdio: 'ignore' })
|
|
53
|
+
let done = false
|
|
54
|
+
const timer = setTimeout(() => { if (!done) { done = true; try { child.kill('SIGKILL') } catch { /* ignore */ } resolve(false) } }, timeoutMs)
|
|
55
|
+
child.on('error', () => { if (!done) { done = true; clearTimeout(timer); resolve(false) } })
|
|
56
|
+
child.on('exit', (code) => { if (!done) { done = true; clearTimeout(timer); resolve(code === 0) } })
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const say = (s) => console.log(s)
|
|
61
|
+
|
|
62
|
+
async function setupHbcli() {
|
|
63
|
+
say('[gotry-setup] hbcli(hotelbyte-cli,可选酒店实时源)')
|
|
64
|
+
const candidates = ['hbcli', join(homedir(), '.local/bin/hbcli'), join(homedir(), '.staicli/current/hbcli')]
|
|
65
|
+
const present = candidates.some((p) => existsSync(p)) && (await probe(candidates[0], ['version']) || await probe(candidates[1], ['version']) || await probe(candidates[2], ['version']))
|
|
66
|
+
if (present) { say(' ✓ 已安装'); return { ok: true } }
|
|
67
|
+
if (CHECK_ONLY) { say(' ✗ 未安装(--check-only 只报告)'); return { ok: true } }
|
|
68
|
+
say(` 安装中(官方脚本): ${HBCLI_INSTALL_CMD}`)
|
|
69
|
+
const r = await run('bash', ['-c', HBCLI_INSTALL_CMD], { timeoutMs: 120_000 })
|
|
70
|
+
if (!r.ok) { say(` ✗ 安装失败(${r.error})——不影响 gotry,酒店检索将用内置静态包;可稍后重试: npx gotry setup`); return { ok: false } }
|
|
71
|
+
const binDir = join(homedir(), '.local/bin')
|
|
72
|
+
if (!process.env.PATH.split(':').includes(binDir)) {
|
|
73
|
+
say(` ⚠ ${binDir} 不在当前 PATH —— gotry 工具已内建候选路径回退,无需手动处理;其他程序可用: export PATH="${binDir}:$PATH"`)
|
|
74
|
+
}
|
|
75
|
+
say(' ✓ 安装完成(凭证选配: hbcli auth set-credentials --app-key ... --app-secret ...,或 HOTELBYTE_TOKEN;未配时酒店检索自动用静态包)')
|
|
76
|
+
return { ok: true }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function setupReach() {
|
|
80
|
+
say('[gotry-setup] agent-reach(可选网页/社媒读取源,pip 装入包内 .venv)')
|
|
81
|
+
const venvBin = join(repoRoot, '.venv/bin/agent-reach')
|
|
82
|
+
if (existsSync(venvBin)) { say(' ✓ 已安装(.venv)'); return { ok: true } }
|
|
83
|
+
if (CHECK_ONLY) { say(' ✗ 未安装(--check-only 只报告)'); return { ok: true } }
|
|
84
|
+
const hasPy = await probe('python3', ['--version'], 10_000)
|
|
85
|
+
if (!hasPy) { say(' ✗ 跳过:未找到 python3(agent-reach 需 Python 3;装好后重跑 npx gotry setup)'); return { ok: false } }
|
|
86
|
+
say(` 创建 .venv 并安装上游(${REACH_INSTALL_URL})`)
|
|
87
|
+
const venv = await run('python3', ['-m', 'venv', join(repoRoot, '.venv')], { timeoutMs: 120_000 })
|
|
88
|
+
if (!venv.ok) { say(` ✗ venv 创建失败(${venv.error})——不影响 gotry,gotry_agent_reach 将返回 not-installed 指引`); return { ok: false } }
|
|
89
|
+
const pip = join(repoRoot, '.venv/bin/pip')
|
|
90
|
+
const inst = await run(pip, ['install', '-q', REACH_INSTALL_URL], { timeoutMs: 300_000 })
|
|
91
|
+
if (!inst.ok) { say(` ✗ pip 安装失败(${inst.error})——可稍后重试: npx gotry setup`); return { ok: false } }
|
|
92
|
+
say(' ✓ 安装完成(渠道凭证选配见 docs/tokens.md: .venv/bin/agent-reach configure --from-browser chrome --platform <渠道>)')
|
|
93
|
+
return { ok: true }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function main() {
|
|
97
|
+
if (process.platform === 'win32') {
|
|
98
|
+
say('[gotry-setup] Windows 暂不支持自动安装(hbcli 上游仅 darwin/linux)。手动指引:')
|
|
99
|
+
say(` hbcli: ${HBCLI_INSTALL_CMD}(WSL);agent-reach: python -m venv .venv && .venv/Scripts/pip install ${REACH_INSTALL_URL}`)
|
|
100
|
+
process.exit(AUTO ? 0 : 1)
|
|
101
|
+
}
|
|
102
|
+
if (AUTO && (process.env.CI || process.env.GOTRY_SETUP_SKIP === '1')) {
|
|
103
|
+
say('[gotry-setup] CI/GOTRY_SETUP_SKIP 检测——跳过外部依赖自举(可随时手动: npx gotry setup)')
|
|
104
|
+
say('GoTry installed. Run: npx gotry web (dsh Web UI on :3080)')
|
|
105
|
+
process.exit(0)
|
|
106
|
+
}
|
|
107
|
+
if (!AUTO && process.env.GOTRY_SETUP_SKIP === '1') { say('[gotry-setup] GOTRY_SETUP_SKIP=1——跳过'); process.exit(0) }
|
|
108
|
+
const results = []
|
|
109
|
+
if (process.env.GOTRY_SETUP_HBCLI !== '0') results.push(await setupHbcli())
|
|
110
|
+
else say('[gotry-setup] hbcli:GOTRY_SETUP_HBCLI=0 跳过')
|
|
111
|
+
if (process.env.GOTRY_SETUP_REACH !== '0') results.push(await setupReach())
|
|
112
|
+
else say('[gotry-setup] agent-reach:GOTRY_SETUP_REACH=0 跳过')
|
|
113
|
+
say('[gotry-setup] flyai:无需安装(npx 每次自拉 @fly-ai/flyai-cli,免 key)')
|
|
114
|
+
const failed = results.filter((r) => !r.ok).length
|
|
115
|
+
if (failed > 0) {
|
|
116
|
+
say(`[gotry-setup] ${failed} 项未就绪——gotry 本体不受影响(各能力均有降级路径);可稍后重跑: npx gotry setup`)
|
|
117
|
+
process.exit(AUTO ? 0 : 1)
|
|
118
|
+
}
|
|
119
|
+
say('[gotry-setup] 全部就绪。Run: npx gotry web')
|
|
120
|
+
process.exit(0)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
main().catch((e) => {
|
|
124
|
+
say(`[gotry-setup] 异常:${e.message}(不影响 gotry 本体;可重试 npx gotry setup)`)
|
|
125
|
+
process.exit(AUTO ? 0 : 1)
|
|
126
|
+
})
|
package/bin/gotry-inner.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* 占位(本机绝对路径),随 tarball 分发后对其他机器必错。
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { spawn } from 'node:child_process'
|
|
19
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
20
20
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
21
|
import { fileURLToPath } from 'node:url'
|
|
22
22
|
import { dirname, join } from 'node:path'
|
|
@@ -56,6 +56,7 @@ if (help) {
|
|
|
56
56
|
|
|
57
57
|
Usage:
|
|
58
58
|
gotry web # dsh Web UI on http://127.0.0.1:3080
|
|
59
|
+
gotry setup # 安装可选外部依赖(hbcli 官方脚本 / agent-reach pip;装时已自动跑过)
|
|
59
60
|
gotry "一段完整任务..." # headless 一问一答
|
|
60
61
|
gotry help # this help
|
|
61
62
|
|
|
@@ -68,12 +69,18 @@ Detail: https://github.com/Danceiny/gotry — README
|
|
|
68
69
|
process.exit(0)
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
// mode 决定路径: 'web'/'
|
|
72
|
-
const literal = new Set(['web', 'help', '-h', '--help'])
|
|
72
|
+
// mode 决定路径: 'web'/'setup'/'help' 是字面命令;否则第一段 args[0] 是任务本身的一部分
|
|
73
|
+
const literal = new Set(['web', 'setup', 'help', '-h', '--help'])
|
|
73
74
|
const isLiteral = literal.has(args[0])
|
|
74
75
|
const mode = isLiteral ? args[0] : 'headless'
|
|
75
76
|
const rest = isLiteral ? args.slice(1) : args
|
|
76
77
|
|
|
78
|
+
// setup:外部依赖自举(hbcli/agent-reach),不需要 dsh runtime 与 LLM key,同步分发后即退
|
|
79
|
+
if (mode === 'setup') {
|
|
80
|
+
const r = spawnSync(process.execPath, [join(here, 'gotry-bootstrap.js'), ...rest], { stdio: 'inherit' })
|
|
81
|
+
process.exit(r.status ?? (r.error ? 1 : 0))
|
|
82
|
+
}
|
|
83
|
+
|
|
77
84
|
// --- dsh runtime 定位:repo checkout(vendored)优先,npm 安装走依赖解析 ---
|
|
78
85
|
const vendoredDsh = join(repoRoot, 'ts/dsh-runtime/node_modules/@deepseek-ai/dsh/lib/bin.js')
|
|
79
86
|
let dshBin = ''
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export function hbcliBinCandidates(bin, homeDir = homedir()) {
|
|
5
|
+
if (bin !== 'hbcli') return [
|
|
6
|
+
bin
|
|
7
|
+
];
|
|
8
|
+
return [
|
|
9
|
+
bin,
|
|
10
|
+
join(homeDir, '.local/bin/hbcli'),
|
|
11
|
+
join(homeDir, '.staicli/current/hbcli')
|
|
12
|
+
];
|
|
13
|
+
}
|
|
14
|
+
function attemptHbcli(bin, args, opts) {
|
|
3
15
|
const started = Date.now();
|
|
4
|
-
const bin = opts.hbcliBin ?? 'hbcli';
|
|
5
|
-
const timeoutMs = opts.timeoutMs ?? 15_000;
|
|
6
|
-
const env = opts.env ?? 'uat';
|
|
7
|
-
const envVars = {
|
|
8
|
-
HOTELBYTE_ENV: env
|
|
9
|
-
};
|
|
10
|
-
if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token;
|
|
11
16
|
return new Promise((resolve)=>{
|
|
12
17
|
let stdout = '';
|
|
13
18
|
let stderr = '';
|
|
@@ -15,7 +20,7 @@ export async function callHbcliJson(args, opts = {}) {
|
|
|
15
20
|
const child = spawn(bin, args, {
|
|
16
21
|
env: {
|
|
17
22
|
...process.env,
|
|
18
|
-
...envVars
|
|
23
|
+
...opts.envVars
|
|
19
24
|
}
|
|
20
25
|
});
|
|
21
26
|
const timer = setTimeout(()=>{
|
|
@@ -28,10 +33,10 @@ export async function callHbcliJson(args, opts = {}) {
|
|
|
28
33
|
result: null,
|
|
29
34
|
evidence: `[实时API:hbcli@timeout@${new Date().toISOString()}]`,
|
|
30
35
|
latencyMs: Date.now() - started,
|
|
31
|
-
error: `timeout after ${timeoutMs}ms`
|
|
36
|
+
error: `timeout after ${opts.timeoutMs}ms`
|
|
32
37
|
});
|
|
33
38
|
}
|
|
34
|
-
}, timeoutMs);
|
|
39
|
+
}, opts.timeoutMs);
|
|
35
40
|
child.stdout.on('data', (d)=>{
|
|
36
41
|
stdout += d.toString();
|
|
37
42
|
});
|
|
@@ -82,11 +87,31 @@ export async function callHbcliJson(args, opts = {}) {
|
|
|
82
87
|
result: null,
|
|
83
88
|
evidence: `[实时API:hbcli@spawn_error@${new Date().toISOString()}]`,
|
|
84
89
|
latencyMs: Date.now() - started,
|
|
85
|
-
error: e.message
|
|
90
|
+
error: e.message,
|
|
91
|
+
spawnError: true
|
|
86
92
|
});
|
|
87
93
|
});
|
|
88
94
|
});
|
|
89
95
|
}
|
|
96
|
+
export async function callHbcliJson(args, opts = {}) {
|
|
97
|
+
const env = opts.env ?? 'uat';
|
|
98
|
+
const envVars = {
|
|
99
|
+
HOTELBYTE_ENV: env
|
|
100
|
+
};
|
|
101
|
+
if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token;
|
|
102
|
+
const callOpts = {
|
|
103
|
+
timeoutMs: opts.timeoutMs ?? 15_000,
|
|
104
|
+
env,
|
|
105
|
+
envVars
|
|
106
|
+
};
|
|
107
|
+
const candidates = hbcliBinCandidates(opts.hbcliBin ?? 'hbcli');
|
|
108
|
+
let last;
|
|
109
|
+
for (const bin of candidates){
|
|
110
|
+
last = await attemptHbcli(bin, args, callOpts);
|
|
111
|
+
if (!(last.spawnError && candidates.indexOf(bin) < candidates.length - 1)) return last;
|
|
112
|
+
}
|
|
113
|
+
return last;
|
|
114
|
+
}
|
|
90
115
|
export async function searchHotels(query, opts = {}) {
|
|
91
116
|
const hbArgs = [
|
|
92
117
|
'search',
|
|
@@ -110,6 +135,8 @@ export async function searchHotels(query, opts = {}) {
|
|
|
110
135
|
summary: `${query.destination}:hbcli 实时返回${query.checkIn || query.checkOut ? '(日期不传上游 list,以当前窗口房价返回)' : ''}`
|
|
111
136
|
};
|
|
112
137
|
}
|
|
138
|
+
const rawReason = live.error ?? live.via;
|
|
139
|
+
const reason = /ENOENT/i.test(rawReason) ? '未安装 hbcli(可选实时源;npx gotry setup 可按官方脚本安装)' : rawReason;
|
|
113
140
|
const fallback = opts.fallbackPath;
|
|
114
141
|
if (fallback) {
|
|
115
142
|
try {
|
|
@@ -124,19 +151,19 @@ export async function searchHotels(query, opts = {}) {
|
|
|
124
151
|
hotels: {
|
|
125
152
|
stays: matched
|
|
126
153
|
},
|
|
127
|
-
summary: `${query.destination}:hbcli
|
|
154
|
+
summary: `${query.destination}:hbcli 实时源不可用(${reason}),已降级到静态包(公开渠道估算,非实时),命中 ${matched.length} 个住宿块`
|
|
128
155
|
};
|
|
129
156
|
}
|
|
130
157
|
return {
|
|
131
158
|
...live,
|
|
132
159
|
hotels: null,
|
|
133
|
-
summary: `${query.destination}:hbcli
|
|
160
|
+
summary: `${query.destination}:hbcli 实时源不可用(${reason}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`
|
|
134
161
|
};
|
|
135
162
|
} catch {}
|
|
136
163
|
}
|
|
137
164
|
return {
|
|
138
165
|
...live,
|
|
139
|
-
summary: `${query.destination}:hbcli
|
|
166
|
+
summary: `${query.destination}:hbcli 实时源不可用(${reason})且无静态包(仅返回错误)`
|
|
140
167
|
};
|
|
141
168
|
}
|
|
142
169
|
export async function listDestinations(opts = {}) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const repoRoot = join(import.meta.dirname, '..', '..');
|
|
5
|
+
const bootstrap = join(repoRoot, 'bin', 'gotry-bootstrap.js');
|
|
6
|
+
function runBootstrap(extraArgs, extraEnv) {
|
|
7
|
+
try {
|
|
8
|
+
const out = execFileSync('node', [
|
|
9
|
+
bootstrap,
|
|
10
|
+
...extraArgs
|
|
11
|
+
], {
|
|
12
|
+
encoding: 'utf-8',
|
|
13
|
+
timeout: 60_000,
|
|
14
|
+
env: {
|
|
15
|
+
...process.env,
|
|
16
|
+
...extraEnv
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
code: 0,
|
|
21
|
+
out
|
|
22
|
+
};
|
|
23
|
+
} catch (e) {
|
|
24
|
+
const err = e;
|
|
25
|
+
return {
|
|
26
|
+
code: err.status ?? 1,
|
|
27
|
+
out: err.stdout ?? ''
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const c1 = runBootstrap([
|
|
32
|
+
'--check-only'
|
|
33
|
+
], {});
|
|
34
|
+
assert.equal(c1.code, 0, `--check-only 应 exit 0,实际 ${c1.code}\n${c1.out}`);
|
|
35
|
+
assert.ok(c1.out.includes('hbcli'), '报告应含 hbcli 节');
|
|
36
|
+
assert.ok(c1.out.includes('agent-reach'), '报告应含 agent-reach 节');
|
|
37
|
+
assert.ok(c1.out.includes('flyai'), '报告应含 flyai(无需安装)节');
|
|
38
|
+
console.log('1. --check-only 探测报告 exit 0(hbcli/agent-reach/flyai 三节齐)OK');
|
|
39
|
+
const c2 = runBootstrap([
|
|
40
|
+
'--auto'
|
|
41
|
+
], {
|
|
42
|
+
GOTRY_SETUP_SKIP: '1'
|
|
43
|
+
});
|
|
44
|
+
assert.equal(c2.code, 0, '--auto 跳过态应 exit 0(永不挡 npm install)');
|
|
45
|
+
assert.ok(c2.out.includes('跳过'), '应输出跳过说明');
|
|
46
|
+
console.log('2. --auto + GOTRY_SETUP_SKIP=1 → exit 0(安装永不失败)OK');
|
|
47
|
+
const c3 = runBootstrap([], {
|
|
48
|
+
GOTRY_SETUP_SKIP: '1'
|
|
49
|
+
});
|
|
50
|
+
assert.equal(c3.code, 0, '显式模式跳过态应 exit 0');
|
|
51
|
+
assert.ok(c3.out.includes('跳过'), '应输出跳过说明');
|
|
52
|
+
console.log('3. 显式模式 + GOTRY_SETUP_SKIP=1 → exit 0 OK');
|
|
53
|
+
console.log('BOOTSTRAP TESTS: 3/3 OK(探测报告/跳过开关/postinstall 非致命)');
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
//# sourceURL=/Users/bytedance/work/gotry/ts/scripts/bootstrap-tests.ts
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { writeFile, mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { flyaiSearch } from '../capabilities/flyai.js';
|
|
6
|
+
const tmp = await mkdtemp(join(tmpdir(), 'flyai-test-'));
|
|
7
|
+
async function fakeCli(name, code, payload) {
|
|
8
|
+
const p = join(tmp, name);
|
|
9
|
+
await writeFile(p, `#!/bin/sh\necho '${payload}'\nexit ${code}\n`, {
|
|
10
|
+
mode: 0o755
|
|
11
|
+
});
|
|
12
|
+
return p;
|
|
13
|
+
}
|
|
14
|
+
const base = {
|
|
15
|
+
kind: 'flight',
|
|
16
|
+
origin: '上海',
|
|
17
|
+
destination: '丽江',
|
|
18
|
+
depDate: '2026-10-01'
|
|
19
|
+
};
|
|
20
|
+
const sentinelBin = await fakeCli('flyai-sentinel', 0, '{"message":"SentinelBlockException: flow control"}');
|
|
21
|
+
const s = await flyaiSearch({
|
|
22
|
+
...base,
|
|
23
|
+
cliBin: sentinelBin,
|
|
24
|
+
timeoutMs: 5000
|
|
25
|
+
});
|
|
26
|
+
assert.equal(s.ok, false, 'Sentinel 形状应 ok=false');
|
|
27
|
+
assert.equal(s.verdict, 'error', `Sentinel 形状应判 error,实际 ${s.verdict}`);
|
|
28
|
+
assert.match(s.error ?? '', /sentinel/i, `error 应保留 sentinel 字样(供上层限流识别),实际 ${s.error}`);
|
|
29
|
+
assert.match(s.evidence, /\[实时API:flyai@error@/, 'error 证据链标注');
|
|
30
|
+
console.log('1. Sentinel 非业务形状 → error(非静默 miss)OK');
|
|
31
|
+
const missBin = await fakeCli('flyai-miss', 0, '{"data":{"itemList":[]}}');
|
|
32
|
+
const m = await flyaiSearch({
|
|
33
|
+
...base,
|
|
34
|
+
cliBin: missBin,
|
|
35
|
+
timeoutMs: 5000
|
|
36
|
+
});
|
|
37
|
+
assert.equal(m.ok, true, '业务空形状 ok=true');
|
|
38
|
+
assert.equal(m.verdict, 'miss', `空 itemList 应判 miss,实际 ${m.verdict}`);
|
|
39
|
+
assert.match(m.evidence, /0\/0 flight options/, 'miss 证据链 0/0');
|
|
40
|
+
console.log('2. 业务空形状 → miss(0/0)OK');
|
|
41
|
+
const hitPayload = JSON.stringify({
|
|
42
|
+
data: {
|
|
43
|
+
itemList: [
|
|
44
|
+
{
|
|
45
|
+
journeys: [
|
|
46
|
+
{
|
|
47
|
+
segments: [
|
|
48
|
+
{
|
|
49
|
+
marketingTransportNo: '9C6617',
|
|
50
|
+
marketingTransportName: '吉祥航空',
|
|
51
|
+
depDateTime: '2026-10-01 07:55',
|
|
52
|
+
arrDateTime: '2026-10-01 11:20',
|
|
53
|
+
depStationName: '浦东T2',
|
|
54
|
+
arrStationName: '丽江三义',
|
|
55
|
+
duration: 205
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
ticketPrice: '580',
|
|
61
|
+
jumpUrl: 'https://www.fliggy.com/demo'
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const hitBin = await fakeCli('flyai-hit', 0, hitPayload);
|
|
67
|
+
const h = await flyaiSearch({
|
|
68
|
+
...base,
|
|
69
|
+
cliBin: hitBin,
|
|
70
|
+
timeoutMs: 5000
|
|
71
|
+
});
|
|
72
|
+
assert.equal(h.verdict, 'hit', `业务条目应判 hit,实际 ${h.verdict}`);
|
|
73
|
+
assert.equal(h.options?.length, 1, '1 个选项');
|
|
74
|
+
assert.equal(h.options[0].no, '9C6617');
|
|
75
|
+
assert.equal(h.options[0].price, 580, '价格数值解析');
|
|
76
|
+
assert.equal(h.options[0].depStation, '浦东T2');
|
|
77
|
+
assert.match(h.evidence, /1\/1 flight options/, 'hit 证据链 1/1');
|
|
78
|
+
console.log('3. 业务命中 → hit(9C6617 ¥580)OK');
|
|
79
|
+
const failBin = await fakeCli('flyai-fail', 1, '');
|
|
80
|
+
const f = await flyaiSearch({
|
|
81
|
+
...base,
|
|
82
|
+
cliBin: failBin,
|
|
83
|
+
timeoutMs: 5000
|
|
84
|
+
});
|
|
85
|
+
assert.equal(f.ok, false);
|
|
86
|
+
assert.equal(f.verdict, 'error', '非零退出应判 error');
|
|
87
|
+
console.log('4. 非零退出 → error OK');
|
|
88
|
+
await rm(tmp, {
|
|
89
|
+
recursive: true,
|
|
90
|
+
force: true
|
|
91
|
+
});
|
|
92
|
+
console.log('FLYAI TESTS: 4/4 OK(离线假 CLI:Sentinel→error / 空 itemList→miss / 命中→hit / exit≠0→error)');
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
//# sourceURL=/Users/bytedance/work/gotry/ts/scripts/flyai-tests.ts
|
|
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import { writeFile, mkdtemp, rm } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { callHbcliJson, searchHotels } from '../capabilities/hbcli.js';
|
|
5
|
+
import { callHbcliJson, searchHotels, hbcliBinCandidates } from '../capabilities/hbcli.js';
|
|
6
6
|
const tmp = await mkdtemp(join(tmpdir(), 'hbcli-test-'));
|
|
7
7
|
async function fakeBin(name, code, payload) {
|
|
8
8
|
const p = join(tmp, name);
|
|
@@ -112,11 +112,36 @@ const r5 = await searchHotels({
|
|
|
112
112
|
});
|
|
113
113
|
assert.equal(r5.hotels ?? null, null, '无目的地命中时 hotels 为 null(不整包倾倒)');
|
|
114
114
|
assert.match(r5.summary, /无「巴黎」住宿数据/, 'summary 明示静态包无该目的地');
|
|
115
|
-
await
|
|
115
|
+
const r6 = await searchHotels({
|
|
116
|
+
destination: '普吉'
|
|
117
|
+
}, {
|
|
118
|
+
hbcliBin: '/nope/hbcli',
|
|
119
|
+
fallbackPath: fallback
|
|
120
|
+
});
|
|
121
|
+
assert.equal(r6.via, 'hbcli-error', 'no-binary path');
|
|
122
|
+
assert.ok(r6.summary.includes('未安装 hbcli'), `summary 应人话化 ENOENT,实际 ${r6.summary}`);
|
|
123
|
+
assert.ok(r6.summary.includes('gotry setup'), '应带 gotry setup 安装指引');
|
|
124
|
+
assert.ok(r6.summary.includes('降级到静态包'), '人话化后仍指明降级到静态包');
|
|
125
|
+
console.log(`6. ENOENT 人话化 OK:${r6.summary}`);
|
|
126
|
+
{
|
|
127
|
+
const home = '/home/t';
|
|
128
|
+
assert.deepEqual(hbcliBinCandidates('hbcli', home), [
|
|
129
|
+
'hbcli',
|
|
130
|
+
'/home/t/.local/bin/hbcli',
|
|
131
|
+
'/home/t/.staicli/current/hbcli'
|
|
132
|
+
], '默认名应带安装位候选');
|
|
133
|
+
assert.deepEqual(hbcliBinCandidates('hbcli-not-on-path', home), [
|
|
134
|
+
'hbcli-not-on-path'
|
|
135
|
+
], '自定义名单候选(测试确定性)');
|
|
136
|
+
assert.deepEqual(hbcliBinCandidates('/nope/hbcli', home), [
|
|
137
|
+
'/nope/hbcli'
|
|
138
|
+
], '绝对路径单候选');
|
|
139
|
+
console.log('7. hbcliBinCandidates(~/.local/bin + ~/.staicli/current 回退)OK');
|
|
140
|
+
}await rm(tmp, {
|
|
116
141
|
recursive: true,
|
|
117
142
|
force: true
|
|
118
143
|
});
|
|
119
|
-
console.log('HBCLI TESTS:
|
|
144
|
+
console.log('HBCLI TESTS: 8/8 OK (happy / error / no-binary / fallback-filter / fallback-no-match / v0.3.0 旗标回归 / ENOENT 人话化 / 候选路径)');
|
|
120
145
|
|
|
121
146
|
|
|
122
147
|
//# sourceURL=/Users/bytedance/work/gotry/ts/scripts/hbcli-tests.ts
|
package/dist/src/index.js
CHANGED
|
@@ -585,10 +585,14 @@ export function apply(ctx, config) {
|
|
|
585
585
|
}),
|
|
586
586
|
presentResult: (_args, value)=>{
|
|
587
587
|
const r = value;
|
|
588
|
-
const
|
|
588
|
+
const h = r.hotels;
|
|
589
|
+
const liveCount = Array.isArray(h) ? h.length : 0;
|
|
590
|
+
const stays = !Array.isArray(h) && h && typeof h === 'object' ? h.stays : undefined;
|
|
591
|
+
const staticCount = Array.isArray(stays) ? stays.length : 0;
|
|
592
|
+
const tag = r.via === 'hbcli-realtime' ? liveCount ? `实时 ${liveCount} 家` : '实时' : staticCount ? `静态包 ${staticCount} 块` : liveCount ? `${liveCount} 家` : '无结果';
|
|
589
593
|
return {
|
|
590
594
|
card: 'generic',
|
|
591
|
-
title: `酒店:${r.destination ?? ''} ${
|
|
595
|
+
title: `酒店:${r.destination ?? ''} ${tag}`,
|
|
592
596
|
content: [
|
|
593
597
|
{
|
|
594
598
|
type: 'text',
|
|
@@ -875,7 +879,8 @@ export function apply(ctx, config) {
|
|
|
875
879
|
depDate: q.date
|
|
876
880
|
});
|
|
877
881
|
const top = (r.options ?? []).slice(0, 8).map((o)=>`${o.no} ${o.name} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`);
|
|
878
|
-
const
|
|
882
|
+
const label = kind === 'flight' ? '机票' : '火车票';
|
|
883
|
+
const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} ${label}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : r.verdict === 'miss' ? `${q.from}→${q.to} ${q.date} ${label}官方通道正常返回 0 条(常见原因:航线未开放/当日售罄)。${r.evidence}` : `${q.from}→${q.to} ${q.date} ${label}检索失败(可能限流/网络):${r.error ?? ''} ${r.evidence}`;
|
|
879
884
|
return JSON.parse(JSON.stringify({
|
|
880
885
|
...r,
|
|
881
886
|
kind,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danceiny/gotry",
|
|
3
|
-
"version": "0.0.1-rc.
|
|
3
|
+
"version": "0.0.1-rc.14",
|
|
4
4
|
"description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm 包入口 + vendored dsh runtime + 5 行 README 安装路径。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "ts/src/index.ts",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"ts/package.json",
|
|
48
48
|
"cordis.gotry-patch.yml",
|
|
49
49
|
"README.md",
|
|
50
|
+
"README.zh-CN.md",
|
|
50
51
|
"LICENSE",
|
|
51
52
|
"ts/capabilities/session-consent.ts",
|
|
52
53
|
"ts/capabilities/session-login.ts"
|
package/ts/capabilities/hbcli.ts
CHANGED
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { spawn } from 'node:child_process'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { join } from 'node:path'
|
|
16
18
|
|
|
17
19
|
export interface HbcliCallOptions {
|
|
18
|
-
/** hbcli 二进制路径(默认 'hbcli',依赖 PATH) */
|
|
20
|
+
/** hbcli 二进制路径(默认 'hbcli',依赖 PATH;~/.local/bin 等已知安装位自动回退) */
|
|
19
21
|
hbcliBin?: string
|
|
20
22
|
/** 超时(ms) */
|
|
21
23
|
timeoutMs?: number
|
|
@@ -43,23 +45,29 @@ export interface HbcliCallResult {
|
|
|
43
45
|
error?: string
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
+
/**
|
|
49
|
+
* hbcli 二进制候选路径(gotry setup 按官方脚本装到 ~/.local/bin/hbcli,
|
|
50
|
+
* symlink 指向 ~/.staicli/current/hbcli——当 PATH 不含 ~/.local/bin 时裸名
|
|
51
|
+
* spawn 仍 ENOENT,按已知安装位回退)。仅对默认名 'hbcli' 扩展;显式自定义
|
|
52
|
+
* 名(如测试注入的不存在路径)不扩展,保持配置即所用的可测性。
|
|
53
|
+
*/
|
|
54
|
+
export function hbcliBinCandidates(bin: string, homeDir: string = homedir()): string[] {
|
|
55
|
+
if (bin !== 'hbcli') return [bin]
|
|
56
|
+
return [bin, join(homeDir, '.local/bin/hbcli'), join(homeDir, '.staicli/current/hbcli')]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 单个候选的一次 spawn 封装:失败不抛,返回降级结果(spawnError 标记 ENOENT 类失败供上层换候选) */
|
|
60
|
+
function attemptHbcli(
|
|
61
|
+
bin: string,
|
|
48
62
|
args: string[],
|
|
49
|
-
opts: HbcliCallOptions
|
|
50
|
-
): Promise<HbcliCallResult> {
|
|
63
|
+
opts: Required<Pick<HbcliCallOptions, 'timeoutMs' | 'env'>> & { envVars: Record<string, string> },
|
|
64
|
+
): Promise<HbcliCallResult & { spawnError?: boolean }> {
|
|
51
65
|
const started = Date.now()
|
|
52
|
-
const bin = opts.hbcliBin ?? 'hbcli'
|
|
53
|
-
const timeoutMs = opts.timeoutMs ?? 15_000
|
|
54
|
-
const env = opts.env ?? 'uat'
|
|
55
|
-
const envVars: Record<string, string> = { HOTELBYTE_ENV: env }
|
|
56
|
-
if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token
|
|
57
|
-
|
|
58
66
|
return new Promise((resolve) => {
|
|
59
67
|
let stdout = ''
|
|
60
68
|
let stderr = ''
|
|
61
69
|
let settled = false
|
|
62
|
-
const child = spawn(bin, args, { env: { ...process.env, ...envVars } })
|
|
70
|
+
const child = spawn(bin, args, { env: { ...process.env, ...opts.envVars } })
|
|
63
71
|
const timer = setTimeout(() => {
|
|
64
72
|
if (!settled) {
|
|
65
73
|
settled = true
|
|
@@ -67,10 +75,10 @@ export async function callHbcliJson(
|
|
|
67
75
|
resolve({
|
|
68
76
|
via: 'hbcli-error', exitCode: -1, result: null,
|
|
69
77
|
evidence: `[实时API:hbcli@timeout@${new Date().toISOString()}]`,
|
|
70
|
-
latencyMs: Date.now() - started, error: `timeout after ${timeoutMs}ms`,
|
|
78
|
+
latencyMs: Date.now() - started, error: `timeout after ${opts.timeoutMs}ms`,
|
|
71
79
|
})
|
|
72
80
|
}
|
|
73
|
-
}, timeoutMs)
|
|
81
|
+
}, opts.timeoutMs)
|
|
74
82
|
child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
|
|
75
83
|
child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
|
|
76
84
|
child.on('close', (code) => {
|
|
@@ -105,16 +113,35 @@ export async function callHbcliJson(
|
|
|
105
113
|
if (settled) return
|
|
106
114
|
settled = true
|
|
107
115
|
clearTimeout(timer)
|
|
108
|
-
// ENOENT (二进制不存在)
|
|
116
|
+
// ENOENT (二进制不存在) 等也走降级路径;spawnError 供上层按候选路径重试
|
|
109
117
|
resolve({
|
|
110
118
|
via: 'hbcli-error', exitCode: -1, result: null,
|
|
111
119
|
evidence: `[实时API:hbcli@spawn_error@${new Date().toISOString()}]`,
|
|
112
|
-
latencyMs: Date.now() - started, error: (e as Error).message,
|
|
120
|
+
latencyMs: Date.now() - started, error: (e as Error).message, spawnError: true,
|
|
113
121
|
})
|
|
114
122
|
})
|
|
115
123
|
})
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
/** 通用 hbcli JSON 调用封装:失败不抛,而是返回降级结果 */
|
|
127
|
+
export async function callHbcliJson(
|
|
128
|
+
args: string[],
|
|
129
|
+
opts: HbcliCallOptions = {},
|
|
130
|
+
): Promise<HbcliCallResult> {
|
|
131
|
+
const env = opts.env ?? 'uat'
|
|
132
|
+
const envVars: Record<string, string> = { HOTELBYTE_ENV: env }
|
|
133
|
+
if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token
|
|
134
|
+
const callOpts = { timeoutMs: opts.timeoutMs ?? 15_000, env, envVars }
|
|
135
|
+
const candidates = hbcliBinCandidates(opts.hbcliBin ?? 'hbcli')
|
|
136
|
+
let last: HbcliCallResult & { spawnError?: boolean } | undefined
|
|
137
|
+
for (const bin of candidates) {
|
|
138
|
+
last = await attemptHbcli(bin, args, callOpts)
|
|
139
|
+
// spawn 级失败(ENOENT 等)且还有候选 → 换下一个已知安装位;其余失败(退码/超时)无重试意义
|
|
140
|
+
if (!(last.spawnError && candidates.indexOf(bin) < candidates.length - 1)) return last
|
|
141
|
+
}
|
|
142
|
+
return last!
|
|
143
|
+
}
|
|
144
|
+
|
|
118
145
|
/** 高层语义化封装:酒店列表查询(down-tier to 静态包 + 证据链标注) */
|
|
119
146
|
export async function searchHotels(
|
|
120
147
|
query: { destination: string; checkIn?: string; checkOut?: string; adults?: number },
|
|
@@ -129,6 +156,11 @@ export async function searchHotels(
|
|
|
129
156
|
if (live.via === 'hbcli-realtime') {
|
|
130
157
|
return { ...live, hotels: live.result, summary: `${query.destination}:hbcli 实时返回${query.checkIn || query.checkOut ? '(日期不传上游 list,以当前窗口房价返回)' : ''}` }
|
|
131
158
|
}
|
|
159
|
+
// 降级原因人话化(issue #24):hbcli 未安装时按 gotry setup 指引(npm 安装期已
|
|
160
|
+
// 自动跑过官方脚本;PATH 未含 ~/.local/bin 时上方候选路径也已兜住),
|
|
161
|
+
// 裸 "spawn hbcli ENOENT" 读起来像工具坏了——实际静态包降级是设计行为
|
|
162
|
+
const rawReason = live.error ?? live.via
|
|
163
|
+
const reason = /ENOENT/i.test(rawReason) ? '未安装 hbcli(可选实时源;npx gotry setup 可按官方脚本安装)' : rawReason
|
|
132
164
|
// 降级:读静态包,按目的地过滤命中的住宿块(issue #24)——整包倾倒会把无关场景
|
|
133
165
|
// (深圳/普吉/曼谷/云南/大理混装)灌给模型且不指明哪块相关;包内无该目的地时明示
|
|
134
166
|
// 「无数据」而不是伪装成可用结果。
|
|
@@ -144,17 +176,17 @@ export async function searchHotels(
|
|
|
144
176
|
return {
|
|
145
177
|
...live,
|
|
146
178
|
hotels: { stays: matched },
|
|
147
|
-
summary: `${query.destination}:hbcli
|
|
179
|
+
summary: `${query.destination}:hbcli 实时源不可用(${reason}),已降级到静态包(公开渠道估算,非实时),命中 ${matched.length} 个住宿块`,
|
|
148
180
|
}
|
|
149
181
|
}
|
|
150
182
|
return {
|
|
151
183
|
...live,
|
|
152
184
|
hotels: null,
|
|
153
|
-
summary: `${query.destination}:hbcli
|
|
185
|
+
summary: `${query.destination}:hbcli 实时源不可用(${reason}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`,
|
|
154
186
|
}
|
|
155
187
|
} catch { /* 静态包读不到也优雅降级 */ }
|
|
156
188
|
}
|
|
157
|
-
return { ...live, summary: `${query.destination}:hbcli
|
|
189
|
+
return { ...live, summary: `${query.destination}:hbcli 实时源不可用(${reason})且无静态包(仅返回错误)` }
|
|
158
190
|
}
|
|
159
191
|
|
|
160
192
|
/** 高层语义化封装:目的地列表(无数据依赖,通常 hbcli dest 命令可独立调通) */
|