@danceiny/gotry 0.0.1-rc.13 → 0.0.1-rc.15

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.
@@ -0,0 +1,165 @@
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
+ * dsh-better-sidebar → dsh 宿主层插件市场组件(dshmarket.com #1 UI,18.9 万周装):
8
+ * dsh web 侧栏工作台(文件浏览/Markdown/Mermaid/PDF 预览)——
9
+ * gotry 产物(工单交付 md/行程 md)的成熟查看面(issue #25)。
10
+ * 宿主层安装(dsh plugin → ~/.dsh/profiles/web),不进 gotry 依赖。
11
+ *
12
+ * 用法:
13
+ * node bin/gotry-bootstrap.js # 显式安装(缺啥装啥;失败 exit 1)
14
+ * node bin/gotry-bootstrap.js --auto # postinstall 模式:CI/跳过开关检测,任何失败不挡安装(exit 0)
15
+ * node bin/gotry-bootstrap.js --check-only # 只探测报告,不安装(测试钩子)
16
+ *
17
+ * 环境开关:
18
+ * GOTRY_SETUP_SKIP=1 全部跳过
19
+ * GOTRY_SETUP_HBCLI=0 跳过 hbcli
20
+ * GOTRY_SETUP_REACH=0 跳过 agent-reach
21
+ * GOTRY_SETUP_SIDEBAR=0 跳过 dsh-better-sidebar
22
+ *
23
+ * 契约:安装外部依赖永远不挡 gotry 本体——能力层各有降级路径(静态包/not-installed
24
+ * verdict),自举失败只降级体验,不产生故障。凭证(hbcli auth / agent-reach 渠道
25
+ * cookie)属用户资产,不自动配置,装完二进制后给指引。
26
+ */
27
+
28
+ import { spawn } from 'node:child_process'
29
+ import { existsSync } from 'node:fs'
30
+ import { homedir } from 'node:os'
31
+ import { join, dirname } from 'node:path'
32
+ import { fileURLToPath } from 'node:url'
33
+
34
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
35
+ const AUTO = process.argv.includes('--auto')
36
+ const CHECK_ONLY = process.argv.includes('--check-only')
37
+
38
+ const HBCLI_INSTALL_CMD = 'curl -fsSL https://github.com/hotelbyte-com/docs/releases/latest/download/install.sh | bash'
39
+ const REACH_INSTALL_URL = 'git+https://github.com/Panniantong/Agent-Reach.git'
40
+
41
+ /** 带超时的子进程(inherit stdio 让用户看见上游安装进度) */
42
+ function run(cmd, args, { timeoutMs, cwd } = {}) {
43
+ return new Promise((resolve) => {
44
+ const child = spawn(cmd, args, { stdio: 'inherit', cwd, env: process.env })
45
+ let done = false
46
+ const timer = setTimeout(() => {
47
+ if (!done) { done = true; try { child.kill('SIGKILL') } catch { /* ignore */ } }
48
+ }, timeoutMs)
49
+ child.on('error', (e) => { if (!done) { done = true; clearTimeout(timer); resolve({ ok: false, error: e.message }) } })
50
+ child.on('exit', (code) => { if (!done) { done = true; clearTimeout(timer); resolve({ ok: code === 0, error: code === 0 ? undefined : `exit ${code}` }) } })
51
+ })
52
+ }
53
+
54
+ /** 静默探测命令是否可执行(带超时) */
55
+ function probe(cmd, args, timeoutMs = 10_000) {
56
+ return new Promise((resolve) => {
57
+ const child = spawn(cmd, args, { stdio: 'ignore' })
58
+ let done = false
59
+ const timer = setTimeout(() => { if (!done) { done = true; try { child.kill('SIGKILL') } catch { /* ignore */ } resolve(false) } }, timeoutMs)
60
+ child.on('error', () => { if (!done) { done = true; clearTimeout(timer); resolve(false) } })
61
+ child.on('exit', (code) => { if (!done) { done = true; clearTimeout(timer); resolve(code === 0) } })
62
+ })
63
+ }
64
+
65
+ const say = (s) => console.log(s)
66
+
67
+ async function setupHbcli() {
68
+ say('[gotry-setup] hbcli(hotelbyte-cli,可选酒店实时源)')
69
+ const candidates = ['hbcli', join(homedir(), '.local/bin/hbcli'), join(homedir(), '.staicli/current/hbcli')]
70
+ const present = candidates.some((p) => existsSync(p)) && (await probe(candidates[0], ['version']) || await probe(candidates[1], ['version']) || await probe(candidates[2], ['version']))
71
+ if (present) { say(' ✓ 已安装'); return { ok: true } }
72
+ if (CHECK_ONLY) { say(' ✗ 未安装(--check-only 只报告)'); return { ok: true } }
73
+ say(` 安装中(官方脚本): ${HBCLI_INSTALL_CMD}`)
74
+ const r = await run('bash', ['-c', HBCLI_INSTALL_CMD], { timeoutMs: 120_000 })
75
+ if (!r.ok) { say(` ✗ 安装失败(${r.error})——不影响 gotry,酒店检索将用内置静态包;可稍后重试: npx gotry setup`); return { ok: false } }
76
+ const binDir = join(homedir(), '.local/bin')
77
+ if (!process.env.PATH.split(':').includes(binDir)) {
78
+ say(` ⚠ ${binDir} 不在当前 PATH —— gotry 工具已内建候选路径回退,无需手动处理;其他程序可用: export PATH="${binDir}:$PATH"`)
79
+ }
80
+ say(' ✓ 安装完成(凭证选配: hbcli auth set-credentials --app-key ... --app-secret ...,或 HOTELBYTE_TOKEN;未配时酒店检索自动用静态包)')
81
+ return { ok: true }
82
+ }
83
+
84
+ async function setupReach() {
85
+ say('[gotry-setup] agent-reach(可选网页/社媒读取源,pip 装入包内 .venv)')
86
+ const venvBin = join(repoRoot, '.venv/bin/agent-reach')
87
+ if (existsSync(venvBin)) { say(' ✓ 已安装(.venv)'); return { ok: true } }
88
+ if (CHECK_ONLY) { say(' ✗ 未安装(--check-only 只报告)'); return { ok: true } }
89
+ const hasPy = await probe('python3', ['--version'], 10_000)
90
+ if (!hasPy) { say(' ✗ 跳过:未找到 python3(agent-reach 需 Python 3;装好后重跑 npx gotry setup)'); return { ok: false } }
91
+ say(` 创建 .venv 并安装上游(${REACH_INSTALL_URL})`)
92
+ const venv = await run('python3', ['-m', 'venv', join(repoRoot, '.venv')], { timeoutMs: 120_000 })
93
+ if (!venv.ok) { say(` ✗ venv 创建失败(${venv.error})——不影响 gotry,gotry_agent_reach 将返回 not-installed 指引`); return { ok: false } }
94
+ const pip = join(repoRoot, '.venv/bin/pip')
95
+ const inst = await run(pip, ['install', '-q', REACH_INSTALL_URL], { timeoutMs: 300_000 })
96
+ if (!inst.ok) { say(` ✗ pip 安装失败(${inst.error})——可稍后重试: npx gotry setup`); return { ok: false } }
97
+ say(' ✓ 安装完成(渠道凭证选配见 docs/tokens.md: .venv/bin/agent-reach configure --from-browser chrome --platform <渠道>)')
98
+ return { ok: true }
99
+ }
100
+
101
+ async function setupSidebar() {
102
+ say('[gotry-setup] dsh-better-sidebar(dsh web 侧栏工作台,产物查看面 issue #25)')
103
+ const installed = existsSync(join(homedir(), '.dsh/profiles/web/node_modules/dsh-better-sidebar/package.json'))
104
+ if (installed) { say(' ✓ 已安装(~/.dsh/profiles/web)'); return { ok: true } }
105
+ if (CHECK_ONLY) { say(' ✗ 未安装(--check-only 只报告)'); return { ok: true } }
106
+ const SIDEBAR_PKG = 'dsh-better-sidebar@latest'
107
+ // dsh CLI:本包依赖里的 @deepseek-ai/dsh 优先;解析不到走 npx 自拉(官方安装途径同款)
108
+ const { createRequire } = await import('node:module')
109
+ const require_ = createRequire(join(repoRoot, 'package.json'))
110
+ let launched = false
111
+ try {
112
+ const dshBin = require_.resolve('@deepseek-ai/dsh/lib/bin.js')
113
+ say(` 安装中(dsh plugin → web profile): ${SIDEBAR_PKG}`)
114
+ const r = await run(process.execPath, [dshBin, 'plugin', '--profile', 'web', 'add', SIDEBAR_PKG], { timeoutMs: 300_000 })
115
+ launched = true
116
+ if (r.ok) {
117
+ say(' ✓ 安装完成(gotry web 刷新浏览器即见右侧工作台;工作区里的产物 md 可直接预览)')
118
+ return { ok: true }
119
+ }
120
+ say(` ✗ dsh plugin 安装失败(${r.error})——尝试 npx 途径`)
121
+ } catch { /* 本包未携带 @deepseek-ai/dsh */ }
122
+ if (!launched) say(' 本包未携带 @deepseek-ai/dsh,走 npx 途径安装')
123
+ const r2 = await run('npx', ['-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', 'web', 'add', SIDEBAR_PKG], { timeoutMs: 300_000 })
124
+ if (!r2.ok) {
125
+ say(' ✗ 安装失败——不影响 gotry:产物仍可在对话里说「看看我生成的行程」经 gotry_artifacts_list/read 查看;可稍后重试: npx gotry setup')
126
+ return { ok: false }
127
+ }
128
+ say(' ✓ 安装完成(gotry web 刷新浏览器即见右侧工作台)')
129
+ return { ok: true }
130
+ }
131
+
132
+ async function main() {
133
+ if (process.platform === 'win32') {
134
+ say('[gotry-setup] Windows 暂不支持自动安装(hbcli 上游仅 darwin/linux)。手动指引:')
135
+ say(` hbcli: ${HBCLI_INSTALL_CMD}(WSL);agent-reach: python -m venv .venv && .venv/Scripts/pip install ${REACH_INSTALL_URL}`)
136
+ say(' dsh-better-sidebar: npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest')
137
+ process.exit(AUTO ? 0 : 1)
138
+ }
139
+ if (AUTO && (process.env.CI || process.env.GOTRY_SETUP_SKIP === '1')) {
140
+ say('[gotry-setup] CI/GOTRY_SETUP_SKIP 检测——跳过外部依赖自举(可随时手动: npx gotry setup)')
141
+ say('GoTry installed. Run: npx gotry web (dsh Web UI on :3080)')
142
+ process.exit(0)
143
+ }
144
+ if (!AUTO && process.env.GOTRY_SETUP_SKIP === '1') { say('[gotry-setup] GOTRY_SETUP_SKIP=1——跳过'); process.exit(0) }
145
+ const results = []
146
+ if (process.env.GOTRY_SETUP_HBCLI !== '0') results.push(await setupHbcli())
147
+ else say('[gotry-setup] hbcli:GOTRY_SETUP_HBCLI=0 跳过')
148
+ if (process.env.GOTRY_SETUP_REACH !== '0') results.push(await setupReach())
149
+ else say('[gotry-setup] agent-reach:GOTRY_SETUP_REACH=0 跳过')
150
+ if (process.env.GOTRY_SETUP_SIDEBAR !== '0') results.push(await setupSidebar())
151
+ else say('[gotry-setup] dsh-better-sidebar:GOTRY_SETUP_SIDEBAR=0 跳过')
152
+ say('[gotry-setup] flyai:无需安装(npx 每次自拉 @fly-ai/flyai-cli,免 key)')
153
+ const failed = results.filter((r) => !r.ok).length
154
+ if (failed > 0) {
155
+ say(`[gotry-setup] ${failed} 项未就绪——gotry 本体不受影响(各能力均有降级路径);可稍后重跑: npx gotry setup`)
156
+ process.exit(AUTO ? 0 : 1)
157
+ }
158
+ say('[gotry-setup] 全部就绪。Run: npx gotry web')
159
+ process.exit(0)
160
+ }
161
+
162
+ main().catch((e) => {
163
+ say(`[gotry-setup] 异常:${e.message}(不影响 gotry 本体;可重试 npx gotry setup)`)
164
+ process.exit(AUTO ? 0 : 1)
165
+ })
@@ -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'/'help'/'help' 是字面命令;否则第一段 args[0] 是任务本身的一部分
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 = ''
@@ -0,0 +1,217 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { isAbsolute, join, resolve, sep } from 'node:path';
4
+ import { openLedgerIfExists } from '../src/state-ledger.js';
5
+ const MAX_LIST = 50;
6
+ const MAX_WINDOW = 400;
7
+ const MAX_BYTES = 2 * 1024 * 1024;
8
+ const TEXT_EXT_LANG = {
9
+ md: 'markdown',
10
+ txt: 'text',
11
+ json: 'json',
12
+ jsonl: 'json',
13
+ csv: 'csv',
14
+ log: 'text',
15
+ yaml: 'yaml',
16
+ yml: 'yaml'
17
+ };
18
+ const DIR_DENY = [
19
+ 'node_modules',
20
+ '.git'
21
+ ];
22
+ function rootOf(stateRoot) {
23
+ return stateRoot === '.' ? process.cwd() : resolve(stateRoot);
24
+ }
25
+ function underRoot(p, root) {
26
+ const r = resolve(root);
27
+ return p === r || p.startsWith(r + sep);
28
+ }
29
+ function hasDeniedSegment(p) {
30
+ return p.split(sep).some((seg)=>DIR_DENY.includes(seg));
31
+ }
32
+ function asyncDeliverablePath(root, id) {
33
+ return join(root, 'gotry-state', 'async', `${id}.deliverable.md`);
34
+ }
35
+ function listRunsFromLedger(root, tenant, limit) {
36
+ const ledger = openLedgerIfExists(root, tenant);
37
+ if (!ledger) return [];
38
+ const rows = ledger.db.prepare('SELECT id, goal, status, deliverable, updated FROM workflow_runs WHERE tenant_id = ? ORDER BY updated DESC LIMIT ?').all(ledger.tenant, limit);
39
+ return rows.map((r)=>{
40
+ const file = asyncDeliverablePath(root, r.id);
41
+ return {
42
+ source: 'async-run',
43
+ id: r.id,
44
+ title: r.goal,
45
+ path: file,
46
+ status: r.status,
47
+ updated: r.updated,
48
+ bytes: r.deliverable?.length
49
+ };
50
+ });
51
+ }
52
+ async function listDeliverableFiles(root, limit) {
53
+ const dir = join(root, 'gotry-state', 'async');
54
+ let names = [];
55
+ try {
56
+ names = (await readdir(dir)).filter((n)=>n.endsWith('.deliverable.md'));
57
+ } catch {
58
+ return [];
59
+ }
60
+ const entries = [];
61
+ for (const n of names.slice(0, limit * 2)){
62
+ const p = join(dir, n);
63
+ const st = await stat(p).catch(()=>null);
64
+ if (!st?.isFile()) continue;
65
+ entries.push({
66
+ source: 'async-run',
67
+ id: n.replace(/\.deliverable\.md$/, ''),
68
+ title: n.replace(/\.deliverable\.md$/, ''),
69
+ path: p,
70
+ status: existsSync(p.replace(/\.deliverable\.md$/, '.json')) ? 'pending-view' : 'legacy',
71
+ updated: new Date(st.mtimeMs).toISOString(),
72
+ bytes: st.size
73
+ });
74
+ }
75
+ return entries.sort((a, b)=>String(b.updated).localeCompare(String(a.updated)));
76
+ }
77
+ async function listCwdMarkdown(cwd, limit) {
78
+ let dirents;
79
+ try {
80
+ dirents = await readdir(cwd, {
81
+ withFileTypes: true
82
+ });
83
+ } catch {
84
+ return [];
85
+ }
86
+ const entries = [];
87
+ for (const d of dirents){
88
+ if (!d.isFile() || !/\.md$/i.test(d.name) || d.name.startsWith('.')) continue;
89
+ const p = join(cwd, d.name);
90
+ const st = await stat(p).catch(()=>null);
91
+ if (!st) continue;
92
+ entries.push({
93
+ source: 'cwd-file',
94
+ id: d.name,
95
+ title: d.name.replace(/\.md$/i, ''),
96
+ path: p,
97
+ updated: new Date(st.mtimeMs).toISOString(),
98
+ bytes: st.size
99
+ });
100
+ }
101
+ return entries.sort((a, b)=>String(b.updated).localeCompare(String(a.updated))).slice(0, limit);
102
+ }
103
+ export async function listArtifacts(opts) {
104
+ const limit = Math.max(1, Math.min(opts.limit ?? 20, MAX_LIST));
105
+ const root = rootOf(opts.stateRoot);
106
+ const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
107
+ const seenPath = new Set();
108
+ const merged = [];
109
+ for (const e of [
110
+ ...listRunsFromLedger(root, 'local', limit),
111
+ ...await listDeliverableFiles(root, limit),
112
+ ...await listCwdMarkdown(cwd, limit)
113
+ ]){
114
+ if (seenPath.has(e.path)) continue;
115
+ seenPath.add(e.path);
116
+ merged.push(e);
117
+ }
118
+ merged.sort((a, b)=>String(b.updated ?? '').localeCompare(String(a.updated ?? '')));
119
+ const total = merged.length;
120
+ return {
121
+ artifacts: merged.slice(0, limit),
122
+ total,
123
+ truncated: total > limit,
124
+ roots: [
125
+ root,
126
+ cwd
127
+ ]
128
+ };
129
+ }
130
+ export async function readArtifact(opts) {
131
+ const root = rootOf(opts.stateRoot);
132
+ const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
133
+ const raw = String(opts.path ?? '').trim();
134
+ if (!raw) return {
135
+ ok: false,
136
+ error: 'path 必填(来自 gotry_artifacts_list 的 path,或异步工单 id)'
137
+ };
138
+ let text = null;
139
+ let filePath = '';
140
+ if (!raw.includes('/') && !raw.includes('\\') && !raw.includes('.')) {
141
+ const ledger = openLedgerIfExists(root, 'local');
142
+ const run = ledger?.db.prepare('SELECT id, goal, status, deliverable FROM workflow_runs WHERE id = ? AND tenant_id = ?').get(raw, ledger.tenant);
143
+ if (run?.deliverable) {
144
+ text = run.deliverable;
145
+ filePath = asyncDeliverablePath(root, raw);
146
+ } else {
147
+ const p = asyncDeliverablePath(root, raw);
148
+ if (existsSync(p)) {
149
+ filePath = p;
150
+ text = await readFile(p, 'utf-8');
151
+ }
152
+ }
153
+ if (text === null) return {
154
+ ok: false,
155
+ error: `工单 ${raw} 无 deliverable(未交付或不存在)`,
156
+ hint: '先 gotry_artifacts_list 看在册产物'
157
+ };
158
+ }
159
+ if (text === null) {
160
+ const candidates = isAbsolute(raw) ? [
161
+ resolve(raw)
162
+ ] : [
163
+ resolve(cwd, raw),
164
+ resolve(root, raw),
165
+ resolve(root, 'gotry-state', 'async', raw)
166
+ ];
167
+ const allowed = candidates.find((p)=>(underRoot(p, cwd) || underRoot(p, root)) && !hasDeniedSegment(p));
168
+ if (!allowed) {
169
+ return {
170
+ ok: false,
171
+ error: `路径越界:${raw}`,
172
+ hint: `只读 ${root} 与 dsh 工作目录内的文本产物`
173
+ };
174
+ }
175
+ const ext = allowed.slice(allowed.lastIndexOf('.') + 1).toLowerCase();
176
+ if (!TEXT_EXT_LANG[ext]) {
177
+ return {
178
+ ok: false,
179
+ error: `不支持的文件类型 .${ext}`,
180
+ hint: `白名单:${Object.keys(TEXT_EXT_LANG).join('/')}`
181
+ };
182
+ }
183
+ const st = await stat(allowed).catch(()=>null);
184
+ if (!st?.isFile()) return {
185
+ ok: false,
186
+ error: `文件不存在:${raw}`,
187
+ hint: '先 gotry_artifacts_list 看在册产物'
188
+ };
189
+ if (st.size > MAX_BYTES) return {
190
+ ok: false,
191
+ error: `文件过大(${st.size} bytes > ${MAX_BYTES})`
192
+ };
193
+ filePath = allowed;
194
+ text = await readFile(allowed, 'utf-8');
195
+ }
196
+ const allLines = text.split('\n');
197
+ const offset = Math.max(1, Math.min(opts.offset ?? 1, allLines.length));
198
+ const limit = Math.max(1, Math.min(opts.limit ?? MAX_WINDOW, MAX_WINDOW));
199
+ const slice = allLines.slice(offset - 1, offset - 1 + limit);
200
+ const ext = filePath.slice(filePath.lastIndexOf('.') + 1).toLowerCase();
201
+ return {
202
+ ok: true,
203
+ path: filePath,
204
+ offset,
205
+ lines: slice.map((t, i)=>({
206
+ number: offset + i,
207
+ text: t
208
+ })),
209
+ totalLines: allLines.length,
210
+ lang: TEXT_EXT_LANG[ext] ?? 'text',
211
+ content: slice.join('\n'),
212
+ windowed: allLines.length > offset - 1 + slice.length
213
+ };
214
+ }
215
+
216
+
217
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/artifacts.ts
@@ -1,13 +1,18 @@
1
1
  import { spawn } from 'node:child_process';
2
- export async function callHbcliJson(args, opts = {}) {
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 不可用(${live.error ?? live.via}),降级到静态包,命中 ${matched.length} 个住宿块`
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 不可用(${live.error ?? live.via}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`
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 = {}) {