@danceiny/gotry 0.2.0-rc.30 → 0.2.0-rc.32

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 (65) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +119 -136
  4. package/bin/gotry-extension-status.d.ts +12 -0
  5. package/bin/gotry-extension-status.js +113 -0
  6. package/bin/gotry-flyai-verification.d.ts +9 -0
  7. package/bin/gotry-flyai-verification.js +63 -0
  8. package/bin/gotry-hbcli-release.d.ts +3 -0
  9. package/bin/gotry-hbcli-release.js +4 -0
  10. package/bin/gotry-inner.js +2 -1
  11. package/client/client.js +228 -0
  12. package/dist/capabilities/doctor.js +14 -23
  13. package/dist/capabilities/effect.js +45 -12
  14. package/dist/capabilities/flyai-config.js +12 -10
  15. package/dist/capabilities/flyai.js +87 -56
  16. package/dist/capabilities/hbcli-config.js +156 -0
  17. package/dist/capabilities/hbcli.js +29 -2
  18. package/dist/capabilities/resilience.js +6 -6
  19. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  20. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  21. package/dist/capabilities/session/ctrip-city.js +186 -0
  22. package/dist/capabilities/session/extension-bridge.js +8 -1
  23. package/dist/capabilities/session/gui-flight.js +737 -0
  24. package/dist/capabilities/session/gui-hotel.js +413 -0
  25. package/dist/capabilities/session-search.js +98 -11
  26. package/dist/scripts/artifact-client-contract-tests.js +4 -3
  27. package/dist/scripts/artifact-delivery-tests.js +31 -0
  28. package/dist/scripts/bootstrap-tests.js +37 -10
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/doctor-tests.js +36 -7
  31. package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
  32. package/dist/scripts/effect-tests.js +602 -504
  33. package/dist/scripts/extension-tests.js +14 -8
  34. package/dist/scripts/flight-malformed-tests.js +1 -1
  35. package/dist/scripts/flyai-setup-tests.js +93 -5
  36. package/dist/scripts/flyai-tests.js +768 -653
  37. package/dist/scripts/gotry-web-api-tests.js +119 -0
  38. package/dist/scripts/gotry-web-client-tests.js +47 -0
  39. package/dist/scripts/gui-flight-live.js +27 -0
  40. package/dist/scripts/hbcli-tests.js +1 -1
  41. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  42. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  43. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  44. package/dist/scripts/hotel-result-tests.js +169 -0
  45. package/dist/scripts/issue-271-liveness-tests.js +3 -1
  46. package/dist/scripts/session-tests.js +177 -6
  47. package/dist/src/artifact-delivery.js +26 -0
  48. package/dist/src/gotry-web-api.js +219 -0
  49. package/dist/src/index.js +45 -17
  50. package/extension/manifest.json +2 -2
  51. package/package.json +8 -2
  52. package/ts/capabilities/flyai-config.ts +12 -10
  53. package/ts/capabilities/flyai.ts +91 -50
  54. package/ts/capabilities/hbcli-config.ts +128 -0
  55. package/ts/capabilities/hbcli.ts +30 -3
  56. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  57. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  58. package/ts/capabilities/session/ctrip-city.ts +158 -0
  59. package/ts/capabilities/session/extension-bridge.ts +9 -2
  60. package/ts/capabilities/session/gui-flight.ts +792 -0
  61. package/ts/capabilities/session/gui-hotel.ts +451 -0
  62. package/ts/capabilities/session-search.ts +115 -15
  63. package/ts/src/artifact-delivery.ts +25 -0
  64. package/ts/src/gotry-web-api.ts +189 -0
  65. package/ts/src/index.ts +46 -18
package/README.md CHANGED
@@ -153,7 +153,7 @@ Trust is structural, not promised:
153
153
  ## Project Status and Roadmap
154
154
  Current boundary: deterministic planning and evidence-bound read capabilities are available; booking and real-user acceptance remain outside the shipped claim. See the [architecture](docs/architecture.md) and [roadmap](docs/roadmap.md).
155
155
 
156
- **v0.2.0-rc.29** on npm (`latest`). Pre-1.0: the core loop works end to end; evaluation is still at deterministic contracts and validators, with no external scores or uplift claims. External W2A events are contract-only and inert; no real sensor bridge or consumer is active yet.
156
+ **v0.2.0-rc.31** on npm (`latest`). Pre-1.0: the core loop works end to end; evaluation is still at deterministic contracts and validators, with no external scores or uplift claims. External W2A events are contract-only and inert; no real sensor bridge or consumer is active yet.
157
157
 
158
158
  **Working today**
159
159
 
@@ -209,7 +209,7 @@ Documents ship as bilingual pairs (`x.md` English + `x.zh-CN.md` 中文); diverg
209
209
 
210
210
  **Built with**: DeepSeek Harness 0.2.0-rc.2 (root-pinned) · Cordis · Z3 (WASM) · loopx (pipx) · hotelbyte-cli · Agent-Reach v1.5.0 · OpenFlights · TypeScript
211
211
 
212
- **Version baseline: `v0.2.0-rc.29` (npm `latest`).** Verification gates: `scripts/run-all-tests.sh`; release flow: `scripts/publish-npm.sh`.
212
+ **Version baseline: `v0.2.0-rc.31` (npm `latest`).** Verification gates: `scripts/run-all-tests.sh`; release flow: `scripts/publish-npm.sh`.
213
213
 
214
214
  <a href="https://www.star-history.com/?repos=danceiny%2Fgotry&type=date&legend=top-left">
215
215
  <picture>
package/README.zh-CN.md CHANGED
@@ -155,7 +155,7 @@ npx @danceiny/gotry "我想从深圳休整两天,预算 3000" # headless 一
155
155
  ## 状态与路线图
156
156
  当前边界:确定性规划与有证据边界的只读能力已经可用;预订与真实用户验收仍不属于当前交付声明。见[架构权威文档](docs/architecture.zh-CN.md)与[路线图](docs/roadmap.zh-CN.md)。
157
157
 
158
- npm `latest`:**v0.2.0-rc.29**。未到 1.0:核心链路已端到端可用;评测仍停留在确定性合同与校验器阶段,无外部分数、无 uplift 声明。外部 W2A 事件仍是合同层、默认 inert;尚无真实 sensor 桥或 consumer 激活。
158
+ npm `latest`:**v0.2.0-rc.31**。未到 1.0:核心链路已端到端可用;评测仍停留在确定性合同与校验器阶段,无外部分数、无 uplift 声明。外部 W2A 事件仍是合同层、默认 inert;尚无真实 sensor 桥或 consumer 激活。
159
159
 
160
160
  **今天可用**
161
161
 
@@ -211,7 +211,7 @@ AI agent:[`AGENTS.md`](AGENTS.zh-CN.md) 是绑定契约——入场先清扫
211
211
 
212
212
  **Built with**: DeepSeek Harness 0.2.0-rc.2 (root-pinned) · Cordis · Z3 (WASM) · loopx (pipx) · hotelbyte-cli · Agent-Reach v1.5.0 · OpenFlights · TypeScript
213
213
 
214
- **版本基线:`v0.2.0-rc.29`(npm `latest`)。** 验证闸:`scripts/run-all-tests.sh`;发布流程:`scripts/publish-npm.sh`。
214
+ **版本基线:`v0.2.0-rc.31`(npm `latest`)。** 验证闸:`scripts/run-all-tests.sh`;发布流程:`scripts/publish-npm.sh`。
215
215
 
216
216
  <a href="https://www.star-history.com/?repos=danceiny%2Fgotry&type=date&legend=top-left">
217
217
  <picture>
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * gotry 外部依赖自举(founder 2026-08-29 指令:安装 gotry 时按上游本身的方式装好外部依赖):
4
- * hbcli(hotelbyte-cli) → npm 优先(staicli@npmjs;Node ≥ 20),原生二进制 install.sh 兜底
4
+ * hbcli(hotelbyte-cli) → 官方版本钉扎的原生 install.sh(npm 发布暂落后)
5
5
  * agent-reach → 官方 pip 安装 git+ upstream(包内 .venv,与 z3-solver 同址原则)
6
6
  * flyai → 无需安装(npx 每次自拉 @fly-ai/flyai-cli)
7
7
  * dsh-better-sidebar → dsh 宿主层插件市场组件(dshmarket.com #1 UI,18.9 万周装):
@@ -53,6 +53,7 @@
53
53
  * 自渲。wizard 子命令退化为「离线健康探活等待」,只输出 stdout,不挡用户。
54
54
  */
55
55
 
56
+ import { verifyFlyaiCandidate } from './gotry-flyai-verification.js'
56
57
  import { spawn } from 'node:child_process'
57
58
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
58
59
  import { homedir } from 'node:os'
@@ -60,6 +61,8 @@ import { join, dirname } from 'node:path'
60
61
  import { fileURLToPath } from 'node:url'
61
62
  import { createRequire } from 'node:module'
62
63
  import { createHash } from 'node:crypto'
64
+ import { checkExtensionStatus } from './gotry-extension-status.js'
65
+ import { HBCLI_RELEASE_VERSION, HBCLI_INSTALL_CMD } from './gotry-hbcli-release.js'
63
66
 
64
67
  // ── FlyAI 凭据面(纯 JS 内联;与 ts/capabilities/flyai-config.ts 成对维护,
65
68
  // 口径以官方 @fly-ai/flyai-cli 1.0.16 解包事实为准。bootstrap 由 inner 以
@@ -162,24 +165,24 @@ function verifySecureModeInline(dir, file) {
162
165
 
163
166
  /**
164
167
  * 官方 CLI 首次运行可能先创建 ~/.flyai 为 0755(并写入 0600 device-id)。
165
- * 仅对当前用户拥有、且本身不是 symlink 的 .flyai 目录收紧权限;.gotry
166
- * 仍由 atomicWriteJsonInline 严格要求预先存在时就是 0700,不走此兼容路径。
168
+ * 仅对当前用户拥有、且本身不是 symlink 的目录收紧权限。
169
+ * 回执所在 .gotry 同样兼容 doctor/runtime 创建的既有 0755 目录。
167
170
  */
168
- function prepareFlyaiConfigDirInline() {
169
- const dir = dirname(flyaiConfigFile())
171
+ function preparePrivateDirInline(file = flyaiConfigFile()) {
172
+ const dir = dirname(file)
170
173
  try {
171
174
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
172
175
  const entry = lstatSync(dir)
173
- if (entry.isSymbolicLink()) return { ok: false, error: '.flyai 目录是符号链接,拒绝写入' }
174
- if (!entry.isDirectory()) return { ok: false, error: '.flyai 路径不是目录,拒绝写入' }
176
+ if (entry.isSymbolicLink()) return { ok: false, error: '配置目录是符号链接,拒绝写入' }
177
+ if (!entry.isDirectory()) return { ok: false, error: '配置路径不是目录,拒绝写入' }
175
178
  if (typeof process.getuid === 'function' && entry.uid !== process.getuid()) {
176
- return { ok: false, error: '.flyai 目录不属于当前用户,拒绝写入' }
179
+ return { ok: false, error: '配置目录不属于当前用户,拒绝写入' }
177
180
  }
178
181
  if ((entry.mode & 0o777) !== 0o700) chmodSync(dir, 0o700)
179
182
  const after = lstatSync(dir)
180
- if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '.flyai 目录状态发生变化,拒绝写入' }
181
- if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '.flyai 目录所有者发生变化,拒绝写入' }
182
- return verifySecureModeInline(dir, flyaiConfigFile())
183
+ if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '配置目录状态发生变化,拒绝写入' }
184
+ if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '配置目录所有者发生变化,拒绝写入' }
185
+ return verifySecureModeInline(dir, file)
183
186
  } catch (e) {
184
187
  return { ok: false, error: e.message }
185
188
  }
@@ -267,7 +270,7 @@ async function runSetupFlyai() {
267
270
  say(' 验证: 未配置——可直接匿名试用(共享额度易达限),或粘贴正式 key 验证后保存')
268
271
  }
269
272
  if (extra) say(extra)
270
- say(' 凭据修改仅本机命令可达: `gotry setup flyai`(隐藏输入)/ `--stdin` / `--clear`;模型工具不能传 key')
273
+ say(' 本机配置:`npx @danceiny/gotry setup flyai`(隐藏输入);`--status` 查看;`--clear` 清除;模型工具不能传 key')
271
274
  }
272
275
 
273
276
  if (STATUS) {
@@ -289,7 +292,7 @@ async function runSetupFlyai() {
289
292
  if (Object.keys(next).length === 0) {
290
293
  rmSync(flyaiConfigFile(), { force: true })
291
294
  } else {
292
- const configDir = prepareFlyaiConfigDirInline()
295
+ const configDir = preparePrivateDirInline()
293
296
  if (!configDir.ok) { say(`[gotry-setup] 清除写入失败,旧配置保留:${configDir.error}`); return 1 }
294
297
  const w = atomicWriteJsonInline(flyaiConfigFile(), next)
295
298
  if (!w.ok) { say(`[gotry-setup] 清除写入失败,旧配置保留:${w.error}`); return 1 }
@@ -315,8 +318,10 @@ async function runSetupFlyai() {
315
318
  // 非 TTY 且未显式 --stdin:仍接受管道输入,但不进入交互隐藏模式。
316
319
  }
317
320
  } else {
318
- say('[gotry-setup] FlyAI API key 设置(输入隐藏,不回显;粘贴后回车)')
319
- say(' 打开 https://flyai.open.fliggy.com/console,登录后复制 API Key。直接回车取消。')
321
+ say('[gotry-setup] FlyAI 配置向导(无需设置环境变量或手工编辑文件)')
322
+ say(' 1. 打开 https://flyai.open.fliggy.com/console,登录后复制 API Key。')
323
+ say(' 2. 在下面粘贴并回车(输入隐藏,不回显);直接回车取消。请勿把 key 发到聊天里。')
324
+ say(' 3. 自动做只读验证,通过后保存到本机;失败保留原配置。')
320
325
  candidate = await hiddenInput(' key: ')
321
326
  }
322
327
  candidate = (candidate ?? '').trim()
@@ -336,7 +341,7 @@ async function runSetupFlyai() {
336
341
  if (verify.verdict !== 'hit') {
337
342
  say(`[gotry-setup] ✗ 验证未通过(${verify.verdict}):${verify.error ?? ''}`)
338
343
  say(' 配置未改动(失败不覆盖原有效设置)。核对 key 后重试,或 `--clear` 回退匿名。')
339
- return 1
344
+ return verify.exitCode ?? 1
340
345
  }
341
346
 
342
347
  const before = readFlyaiConfigObject()
@@ -345,7 +350,7 @@ async function runSetupFlyai() {
345
350
  return 1
346
351
  }
347
352
  const hadConfigFile = existsSync(flyaiConfigFile())
348
- const configDir = prepareFlyaiConfigDirInline()
353
+ const configDir = preparePrivateDirInline()
349
354
  if (!configDir.ok) {
350
355
  say(`[gotry-setup] ✗ 配置目录不安全,旧配置保留:${configDir.error}`)
351
356
  return 1
@@ -376,7 +381,8 @@ async function runSetupFlyai() {
376
381
  endpointDebug: epAfter.debug,
377
382
  at: new Date().toISOString(),
378
383
  }
379
- const receiptWrite = atomicWriteJsonInline(flyaiVerificationFile(), receipt)
384
+ const receiptDir = preparePrivateDirInline(flyaiVerificationFile())
385
+ const receiptWrite = receiptDir.ok ? atomicWriteJsonInline(flyaiVerificationFile(), receipt) : receiptDir
380
386
  if (!receiptWrite.ok) {
381
387
  const rollback = hadConfigFile
382
388
  ? atomicWriteJsonInline(flyaiConfigFile(), before.data)
@@ -393,7 +399,6 @@ async function runSetupFlyai() {
393
399
  /** TTY 隐藏输入:raw mode,不回显;Enter 结束;Ctrl+C/Ctrl+D 取消 */
394
400
  function hiddenInput(prompt) {
395
401
  return new Promise((resolve) => {
396
- process.stdout.write(prompt)
397
402
  let buf = ''
398
403
  const stdin = process.stdin
399
404
  stdin.setRawMode(true)
@@ -433,6 +438,9 @@ function hiddenInput(prompt) {
433
438
  }
434
439
  }
435
440
  stdin.on('data', onData)
441
+ // Disable terminal echo before advertising readiness: a fast paste can
442
+ // reach the PTY as soon as the prompt is written, even within this tick.
443
+ process.stdout.write(prompt)
436
444
  })
437
445
  }
438
446
 
@@ -441,73 +449,26 @@ function hiddenInput(prompt) {
441
449
  * 子进程 env scrub FLYAI_API_KEY/DEBUG_FLYAI_API_KEY 后注入候选。
442
450
  * 返回 {verdict,error}。
443
451
  */
444
- function verifyCandidateKeyInline(candidate) {
445
- const GOTRY_FLYAI_CLI = process.env.GOTRY_FLYAI_CLI_BIN ?? ''
446
- const cliBin = GOTRY_FLYAI_CLI || 'npx'
447
- const prefix = GOTRY_FLYAI_CLI ? [] : ['-y', '@fly-ai/flyai-cli@1.0.16']
448
- const env = { ...process.env }
449
- delete env.FLYAI_API_KEY
450
- delete env.DEBUG_FLYAI_API_KEY
451
- env.FLYAI_API_KEY = candidate
452
- const date = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10)
453
- const args = [...prefix, 'search-flight', '--origin', '上海', '--destination', '丽江', '--dep-date', date]
454
- const configuredTimeout = Number(process.env.GOTRY_FLYAI_VERIFY_TIMEOUT_MS ?? 20_000)
455
- const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? Math.min(configuredTimeout, 60_000) : 20_000
456
- return new Promise((resolve) => {
457
- let stdout = ''
458
- let stderr = ''
459
- let timedOut = false
460
- let settled = false
461
- const child = spawn(cliBin, args, {
462
- env, stdio: ['ignore', 'pipe', 'pipe'],
463
- detached: process.platform !== 'win32',
464
- })
465
- const finish = (v) => {
466
- if (settled) return
467
- settled = true
468
- resolve(v)
469
- }
470
- const killGroup = (signal) => {
471
- if (process.platform !== 'win32' && child.pid) {
472
- try { process.kill(-child.pid, signal); return } catch { /* fall through */ }
473
- }
474
- try { child.kill(signal) } catch { /* ignore */ }
452
+ async function verifyCandidateKeyInline(candidate) {
453
+ const controller = new AbortController()
454
+ let interrupted
455
+ const listeners = new Map()
456
+ for (const signal of ['SIGINT', 'SIGTERM']) {
457
+ const listener = () => {
458
+ interrupted ??= signal
459
+ controller.abort()
475
460
  }
476
- const timer = setTimeout(() => { timedOut = true; killGroup('SIGKILL') }, timeoutMs)
477
- child.stdout?.on('data', d => { stdout += d.toString() })
478
- child.stderr?.on('data', d => { stderr += d.toString() })
479
- child.on('error', (e) => { clearTimeout(timer); finish({ verdict: 'error', error: e.message }) })
480
- child.on('close', (code) => {
481
- clearTimeout(timer)
482
- const combined = `${stderr}\n${stdout}`
483
- if (timedOut) return finish({ verdict: 'timeout', error: `验证调用超时 ${timeoutMs}ms` })
484
- if (/Invalid API key|HTTP\s*401|\b401\b/.test(combined)) {
485
- return finish({ verdict: 'auth-error', error: '401 Invalid API key' })
486
- }
487
- if (/HTTP\s*403|\b403\b/.test(combined)) {
488
- return finish({ verdict: 'forbidden', error: 'HTTP 403:无访问权限,请核对控制台权限。' })
489
- }
490
- if (/Trial limit reached/.test(combined)) {
491
- return finish({ verdict: 'needs-setup', error: 'Trial limit reached(候选 key 未被识别为正式 key?)' })
492
- }
493
- if (/HTTP\s*429|\b429\b/.test(combined)) {
494
- return finish({ verdict: 'rate-limited', error: '普通限流,稍后重试' })
495
- }
496
- if (code !== 0) {
497
- // Upstream diagnostics can echo credentials or a private debug URL.
498
- return finish({ verdict: 'error', error: `验证进程失败(exit ${code});请检查网络与服务状态。` })
499
- }
500
- try {
501
- const envelope = JSON.parse(stdout)
502
- if (Array.isArray(envelope?.data?.itemList)) {
503
- return finish({ verdict: 'hit', count: envelope.data.itemList.length })
504
- }
505
- return finish({ verdict: 'error', error: '响应缺 data.itemList' })
506
- } catch {
507
- return finish({ verdict: 'error', error: '响应不是合法 JSON' })
508
- }
509
- })
510
- })
461
+ listeners.set(signal, listener)
462
+ process.on(signal, listener)
463
+ }
464
+ try {
465
+ const result = await verifyFlyaiCandidate(candidate, { signal: controller.signal })
466
+ return interrupted
467
+ ? { ...result, verdict: 'cancelled', exitCode: 128 + (interrupted === 'SIGINT' ? 2 : 15) }
468
+ : result
469
+ } finally {
470
+ for (const [signal, listener] of listeners) process.off(signal, listener)
471
+ }
511
472
  }
512
473
 
513
474
 
@@ -521,9 +482,6 @@ const WIZARD_DRY_RUN = process.argv.includes('--dry-run')
521
482
  const EXT_FROM_ARG = (process.argv.find((a) => a.startsWith('--extension-from=')) ?? '').split('=')[1]
522
483
  const EXTENSION_FROM = EXT_FROM_ARG === 'github' || EXT_FROM_ARG === 'bundled' ? EXT_FROM_ARG : process.env.GOTRY_EXTENSION_SOURCE === 'github' ? 'github' : 'bundled'
523
484
 
524
- // hbcli 安装通道:npm 优先(staicli@npmjs 双轨;显式 registry 防 bnpm 404),install.sh 兜底
525
- const HBCLI_NPM_INSTALL_CMD = 'npm install -g staicli --registry=https://registry.npmjs.org/'
526
- const HBCLI_INSTALL_CMD = HBCLI_NPM_INSTALL_CMD
527
485
  const REACH_INSTALL_URL = 'git+https://github.com/Panniantong/Agent-Reach.git'
528
486
 
529
487
  let activeInstallerChild = null
@@ -672,11 +630,8 @@ function classifyHbcliWhoami(out) {
672
630
 
673
631
  const say = (s) => console.log(s)
674
632
 
675
- /** hbcli 最低版本(可用 GOTRY_MIN_HBCLI_VERSION 覆盖)。
676
- * 0.0.3 修了 portal-ticket fallback bug(issue #142):低于该版本的 hbcli
677
- * 会把 literal "stored-ticket" 当 bearer 发到 hotel-be,让 trade.* /
678
- * search/checkAvail 等 @permission: openapi 端点全部 401。 */
679
- const MIN_HBCLI_VERSION = process.env.GOTRY_MIN_HBCLI_VERSION ?? '0.0.3'
633
+ /** hbcli 最低版本(可用 GOTRY_MIN_HBCLI_VERSION 覆盖),与工具层 doctor 共用发布基线。 */
634
+ const MIN_HBCLI_VERSION = process.env.GOTRY_MIN_HBCLI_VERSION ?? HBCLI_RELEASE_VERSION
680
635
 
681
636
  function parseVersion(v) {
682
637
  const m = String(v || '').match(/(\d+)\.(\d+)\.(\d+)/)
@@ -704,38 +659,53 @@ async function hbcliVersion(bin) {
704
659
  })
705
660
  }
706
661
 
707
- async function setupHbcli() {
708
- say(`[gotry-setup] hbcli(hotelbyte-cli,可选酒店实时源;需 ≥ v${MIN_HBCLI_VERSION},低于会踩 issue #142 的 401)`)
709
- const candidates = ['hbcli', join(homedir(), '.local/bin/hbcli'), join(homedir(), '.staicli/current/hbcli')]
710
- const present = candidates.some((p) => existsSync(p)) && (await probe(candidates[0], ['version']) || await probe(candidates[1], ['version']) || await probe(candidates[2], ['version']))
711
- const credFile = join(homedir(), '.staicli', 'credentials.json')
712
- if (present) {
713
- const hit = candidates.find((p) => existsSync(p)) || 'hbcli'
714
- const v = await hbcliVersion(hit)
715
- if (v && !versionAtLeast(v, MIN_HBCLI_VERSION)) {
716
- say(` ✗ 版本过旧(v${v.join('.')} < v${MIN_HBCLI_VERSION})——@permission: openapi 端点(trade.* / search/checkAvail)会 401(issue #142)`)
717
- say(` 升级: npm install -g staicli --registry=https://registry.npmjs.org/`)
718
- return { ok: CHECK_ONLY ? true : false }
662
+ async function setupHbcli(opts = {}) {
663
+ const home = opts.homeDir ?? homedir()
664
+ const staicliHome = opts.homeDir ? join(home, '.staicli') : process.env.STAICLI_HOME ?? join(home, '.staicli')
665
+ const checkOnly = opts.checkOnly ?? CHECK_ONLY
666
+ say(`[gotry-setup] hbcli(hotelbyte-cli,可选酒店实时源;需 ≥ v${MIN_HBCLI_VERSION})`)
667
+ const candidates = ['hbcli', join(home, '.local/bin/hbcli'), join(home, '.staicli/current/hbcli')]
668
+ const findInstalled = async () => {
669
+ for (const bin of candidates) {
670
+ const version = await hbcliVersion(bin)
671
+ if (version) return { bin, version }
719
672
  }
720
- say(` ✓ 已安装(v${v ? v.join('.') : '?'})`)
673
+ return null
674
+ }
675
+ const present = await findInstalled()
676
+ const credFile = join(staicliHome, 'credentials.json')
677
+ if (present && versionAtLeast(present.version, MIN_HBCLI_VERSION)) {
678
+ say(` ✓ 已安装(v${present.version.join('.')})`)
721
679
  if (existsSync(credFile)) say(' ✓ 凭证已配置(自检: hbcli auth whoami)')
722
680
  else say(' ✗ 凭证未配置——酒店检索将用内置静态包(非实时),账号配置见下方指引')
723
- if (CHECK_ONLY) say(' (--check-only 只报告,不安装)')
681
+ if (checkOnly) say(' (--check-only 只报告,不安装)')
724
682
  return { ok: true }
725
683
  }
726
- if (CHECK_ONLY) { say(' ✗ 未安装(--check-only 只报告)'); return { ok: true } }
727
- say(` 安装中(npm,staicli@npmjs): ${HBCLI_INSTALL_CMD}`)
728
- const r = await run('bash', ['-c', HBCLI_INSTALL_CMD], { timeoutMs: 120_000 })
729
- if (!r.ok) { say(` ✗ 安装失败(${r.error})——不影响 gotry,酒店检索将用内置静态包;可稍后重试: npx @danceiny/gotry setup`); return { ok: false } }
730
- const binDir = join(homedir(), '.local/bin')
731
- if (!process.env.PATH.split(':').includes(binDir)) {
684
+ if (present) say(` ✗ 版本过旧(v${present.version.join('.')} < v${MIN_HBCLI_VERSION})`)
685
+ else say(' ✗ 未安装或无法读取版本')
686
+ if (checkOnly) { say(' (--check-only 只报告,不安装)'); return { ok: true } }
687
+ say(` ${present ? '升级' : '安装'}中(官方 staicli v${HBCLI_RELEASE_VERSION}): ${HBCLI_INSTALL_CMD}`)
688
+ const attemptInstall = opts.attemptInstall ?? ((command) => run('bash', ['-o', 'pipefail', '-c', command], {
689
+ timeoutMs: 120_000,
690
+ env: { ...process.env, HOME: home, STAICLI_HOME: staicliHome },
691
+ }))
692
+ const r = await attemptInstall(HBCLI_INSTALL_CMD)
693
+ if (!r.ok) { say(` ✗ 安装失败(${r.error})——不影响 gotry,酒店检索将用内置静态包;可稍后重试: npx @danceiny/gotry doctor --fix`); return { ok: false, error: r.error } }
694
+ const installed = await findInstalled()
695
+ if (!installed || !versionAtLeast(installed.version, MIN_HBCLI_VERSION)) {
696
+ const error = `安装后当前候选路径仍未找到 hbcli ≥ v${MIN_HBCLI_VERSION};检查 PATH 是否优先命中了旧版,以及 ${join(home, '.local/bin/hbcli')}`
697
+ say(` ✗ ${error}`)
698
+ return { ok: false, error }
699
+ }
700
+ const binDir = join(home, '.local/bin')
701
+ if (!(process.env.PATH ?? '').split(':').includes(binDir)) {
732
702
  say(` ⚠ ${binDir} 不在当前 PATH —— gotry 工具已内建候选路径回退,无需手动处理;其他程序可用: export PATH="${binDir}:$PATH"`)
733
703
  }
734
- say(' ✓ 安装完成——账号配置(未配时酒店检索自动用静态包,非实时):')
735
- say(' · 快速试用(官方沙箱演示账号): hbcli auth set-credentials --app-key hotelbyte_api_demo --app-secret hotelbyte_api_demo')
736
- say(' · 正式接入: 向 HotelByte 申请专属 appKey/appSecret 后用同一命令替换')
737
- say(' · 门户账号(权限更大,酒店搜索无必要): hbcli auth login --username <email>')
738
- say(' 自检: hbcli auth whoami(api_key.configured=true 即就位)')
704
+ say(` ✓ 安装完成(v${installed.version.join('.')})——已有凭证保留;账号配置:`)
705
+ say(' · 打开 插件 → GoTry → HotelByte,申请沙箱账号并验证保存 App Key/App Secret')
706
+ say(' · 申请入口: https://hotelbyte.com/zh/guides/sandbox-verification')
707
+ say(' · CLI 配置: hbcli auth set-credentials --app-key <appKey> --app-secret <appSecret>')
708
+ say(' 自检: hbcli auth whoami;实际酒店搜索仍需账号开通可用供应商')
739
709
  return { ok: true }
740
710
  }
741
711
 
@@ -889,16 +859,15 @@ async function doctorChecks() {
889
859
  const nodeFine = maj > 22 || (maj === 22 && min >= 15)
890
860
  items.push({ label: 'Node 运行时', ok: nodeFine, level: nodeFine ? 'ok' : 'missing', detail: `Node ${nv}(需 ≥22.15)`, fix: nodeFine ? undefined : '升级 Node.js 至 22.15+(https://nodejs.org)' })
891
861
  // 扩展
892
- const extManifest = join(homedir(), '.gotry', 'extension', 'manifest.json')
893
- const extOk = existsSync(extManifest)
894
- items.push({ label: 'Stai Travel Bridge 扩展', ok: extOk, level: extOk ? 'ok' : 'missing', detail: extOk ? `已就位(${extManifest})` : '未安装——影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响', fix: extOk ? undefined : '在 Chrome 应用商店一键安装(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd' })
862
+ const extension = await checkExtensionStatus()
863
+ items.push({ label: 'Stai Travel Bridge 扩展', ok: extension.status === 'ok', level: extension.status, detail: extension.detail, fix: extension.fix })
895
864
  // agent-reach(.venv 装在包内)
896
865
  const venvPython = join(repoRoot, '.venv/bin/python')
897
866
  const reachBin = join(repoRoot, '.venv/bin/agent-reach')
898
867
  const reachOk = existsSync(reachBin)
899
868
  const reachLevel = reachOk ? 'ok' : existsSync(venvPython) ? 'degraded' : 'missing'
900
869
  items.push({ label: 'Agent Reach(网页/社媒读取)', ok: reachOk, level: reachLevel, detail: reachOk ? `已安装(${reachBin})` : reachLevel === 'degraded' ? '.venv 在但缺 agent-reach 包——gotry_agent_reach / gotry_web_search 读页会失败' : '未装配——gotry_agent_reach / gotry_web_search(读网页)/ gotry_video_subtitle / gotry_github_search 全部不可用', fix: reachOk ? undefined : 'npx @danceiny/gotry doctor --fix' })
901
- // hbcli(裸名靠 PATH 探测;绝对路径 existsSync;版本 < MIN_HBCLI_VERSION 视为 missing,见 issue #142)
870
+ // hbcli(裸名靠 PATH 探测;绝对路径 existsSync;版本 < MIN_HBCLI_VERSION 需要升级)
902
871
  let hbBin = ''
903
872
  for (const p of ['hbcli', join(homedir(), '.local/bin/hbcli'), join(homedir(), '.staicli/current/hbcli')]) {
904
873
  if (p === 'hbcli') { if (await probe(p, ['version'])) { hbBin = 'hbcli(PATH)'; break } } else if (existsSync(p)) { hbBin = p; break }
@@ -906,7 +875,7 @@ async function doctorChecks() {
906
875
  if (hbBin) {
907
876
  const v = await hbcliVersion(hbBin)
908
877
  if (v && !versionAtLeast(v, MIN_HBCLI_VERSION)) {
909
- items.push({ label: 'hbcli(酒店实时源)', ok: false, level: 'missing', detail: `版本过旧(v${v.join('.')} < v${MIN_HBCLI_VERSION})——trade.* / search/checkAvail 会 401(issue #142)`, fix: 'npm install -g staicli --registry=https://registry.npmjs.org/' })
878
+ items.push({ label: 'hbcli(酒店实时源)', ok: false, level: 'missing', detail: `版本过旧(v${v.join('.')} < v${MIN_HBCLI_VERSION})——请升级到支持当前认证与酒店配置的版本`, fix: HBCLI_INSTALL_CMD })
910
879
  } else {
911
880
  // 凭证按 whoami 的 JSON 三档判定,不再看退出码(#623:whoami 无凭证也退 0,旧判法恒报「凭证有效」)
912
881
  const cred = classifyHbcliWhoami(await probeStdout(hbBin === 'hbcli(PATH)' ? 'hbcli' : hbBin, ['--json', 'auth', 'whoami']))
@@ -915,7 +884,7 @@ async function doctorChecks() {
915
884
  detail: cred.ok
916
885
  ? `已安装且凭证有效(${hbBin}, v${v ? v.join('.') : '?'}${cred.customer ? ',customer 档在用(客户邮箱验证码登录)' : ''})`
917
886
  : '二进制在,但凭证未配置/失效——酒店检索将降级静态包(非实时)',
918
- fix: cred.ok ? undefined : 'hbcli auth customer-send-code --email you@mail.com 收码后 hbcli auth customer-login --email you@mail.com --code <收件码>(客户邮箱验证码登录,新邮箱即注册,需 npm staicli ≥ 0.0.4);B 端租户注册走 hbcli auth register(先 auth send-code);或 hbcli auth set-credentials --app-key hotelbyte_api_demo --app-secret hotelbyte_api_demo(快速试用沙箱;正式 key 向 HotelByte 申请)',
887
+ fix: cred.ok ? undefined : 'hbcli auth customer-send-code --email you@mail.com 收码后 hbcli auth customer-login --email you@mail.com --code <收件码>(客户邮箱验证码登录,新邮箱即注册);B 端租户注册走 hbcli auth register(先 auth send-code);或在 插件 → GoTry → HotelByte 配置沙箱 App Key/App Secret',
919
888
  })
920
889
  }
921
890
  } else {
@@ -931,7 +900,7 @@ async function doctorChecks() {
931
900
  let flyaiFix
932
901
  if (!flyaiResolved.key) {
933
902
  flyaiDetail = '未配置 key——匿名试用中(共享额度易达限;达限报 Trial limit reached)。影响面:共享池额度小,频繁会话易触顶;达限本会话内 gotry_flyai_search(机票/酒店/票务/AI/万豪 8 类)失败,改走 gotry_session_search 账号会话通道。'
934
- flyaiFix = '本机运行 `gotry setup flyai`(隐藏输入,先验证后保存 FLYAI_API_KEY);打开 https://flyai.open.fliggy.com/console,登录后复制 API Key'
903
+ flyaiFix = 'Web 打开「插件 → GoTry → FlyAI」验证并保存;或运行 `npx @danceiny/gotry setup flyai`:① 打开 https://flyai.open.fliggy.com/console 复制 API Key;② 在终端隐藏粘贴;③ 验证后保存。也可运行 `npx @danceiny/gotry doctor --fix` 按提示配置'
935
904
  } else {
936
905
  const keySha = sha256Inline(flyaiResolved.key)
937
906
  const receiptMatches = flyaiReceipt
@@ -946,16 +915,16 @@ async function doctorChecks() {
946
915
  flyaiDetail = `已验证 ${flyaiReceipt.at}(${sourceNote};endpoint ${displayEndpointInline(flyaiEndpoint.url)}${flyaiEndpoint.debug ? '(DEBUG)' : ''})`
947
916
  } else if (flyaiReceipt && flyaiReceipt.keySha256 === keySha && flyaiReceipt.verdict !== 'verified') {
948
917
  flyaiDetail = `已配置未通过验证(${flyaiReceipt.verdict}@ ${flyaiReceipt.at};${sourceNote};endpoint ${displayEndpointInline(flyaiEndpoint.url)}${flyaiEndpoint.debug ? '(DEBUG)' : ''})——非空不等于鉴权通过`
949
- flyaiFix = '本机运行 `gotry setup flyai` 重新验证;`gotry setup flyai --clear` 回退匿名'
918
+ flyaiFix = 'Web 打开「插件 → GoTry → FlyAI」重新验证;或运行 `npx @danceiny/gotry setup flyai`;`npx @danceiny/gotry setup flyai --clear` 回退匿名'
950
919
  } else {
951
920
  flyaiDetail = `已配置,未验证(${sourceNote};endpoint ${displayEndpointInline(flyaiEndpoint.url)}${flyaiEndpoint.debug ? '(DEBUG)' : ''})`
952
- flyaiFix = '本机运行 `gotry setup flyai`(候选 key scrub-env 验证后保存)'
921
+ flyaiFix = 'Web 打开「插件 → GoTry → FlyAI」验证并保存;或运行 `npx @danceiny/gotry setup flyai`,按提示隐藏粘贴 API Key'
953
922
  }
954
923
  }
955
924
  items.push({ label: 'FlyAI(飞猪官方检索:机/火/酒/景/关键词/AI/万豪 8 类)', ok: flyaiLevel === 'ok', level: flyaiLevel, detail: flyaiDetail, fix: flyaiFix })
956
925
  // sidebar(状态面与 setupSidebar 的落盘复核同一口径)
957
926
  const sbOk = sidebarInstalled()
958
- items.push({ label: 'dsh-better-sidebar(侧栏工作台)', ok: sbOk, level: sbOk ? 'ok' : 'missing', detail: sbOk ? '已安装——web UI 右侧工作台可预览产物与 doctor 报告(gotry-state/doctor-report.md)' : '未安装——dsh web 无右侧工作台,产物与 doctor 报告只能在对话里看(gotry_artifacts_list)', fix: sbOk ? undefined : 'npx @danceiny/gotry doctor --fix' })
927
+ items.push({ label: 'dsh-better-sidebar(侧栏工作台)', ok: sbOk, level: sbOk ? 'ok' : 'missing', detail: sbOk ? '已安装——web UI 右侧工作台可预览产物与 doctor 报告(gotry-state/doctor-report.md)' : '未安装此增强组件——Web 自带 GoTry 产物页与原生预览;可选补装以增强文件浏览', fix: sbOk ? undefined : 'npx @danceiny/gotry doctor --fix' })
959
928
  // dsh-calendar(setup 状态面;默认不挂载=ok 是合法态,opt-in 未配置才 degraded)
960
929
  const calState = readCalendarState()
961
930
  const calOn = calState?.enabled === true
@@ -1018,8 +987,8 @@ const fixCell = (fix) => (!fix ? '—' : /^(npx|hbcli|curl|pip|python|\$)/.test(
1018
987
  function startupDoctorLine(items) {
1019
988
  const broken = (items ?? []).filter((i) => i.level && i.level !== 'ok')
1020
989
  if (broken.length === 0) return null
1021
- const human = (lv) => (lv === 'missing' ? '缺' : '半可用')
1022
- return `[gotry] doctor: ${broken.length} 项待处理(${broken.map((i) => `${i.label}=${human(i.level)}`).join('、')})——对话里让助手调 gotry_doctor 看详情与指引,或终端跑 npx @danceiny/gotry doctor`
990
+ const human = i => i.level === 'missing' ? '缺' : i.label.startsWith('Stai Travel Bridge') ? '待确认' : '半可用'
991
+ return `[gotry] doctor: ${broken.length} 项待处理(${broken.map((i) => `${i.label}=${human(i)}`).join('、')})——对话里让助手调 gotry_doctor 看详情与指引,或终端跑 npx @danceiny/gotry doctor`
1023
992
  }
1024
993
 
1025
994
  /** 体检报告 markdown(与 ts/capabilities/doctor.ts renderDoctorReportMd 同形) */
@@ -1073,11 +1042,26 @@ async function runDoctor() {
1073
1042
  if (process.env.GOTRY_SETUP_SIDEBAR !== '0') results.push(await setupSidebar())
1074
1043
  const failedFix = results.filter((r) => !r.ok).length
1075
1044
  say(failedFix === 0 ? '[gotry-doctor] 补装完成;下面是补装后复检。' : `[gotry-doctor] ${failedFix} 项补装失败——见上方安装器输出;可重跑 npx @danceiny/gotry doctor --fix`)
1076
- items = await doctorChecks()
1077
- for (const i of items) {
1078
- say(` ${doctorIcon[i.level]} ${i.label}:${i.detail}`)
1045
+ }
1046
+ // Explicit --fix may offer local credential setup, but never consumes a
1047
+ // secret from CI, a pipe, startup diagnostics or the model tool surface.
1048
+ if (items.some(i => i.label.startsWith('FlyAI(') && i.level !== 'ok')
1049
+ && process.platform !== 'win32' && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) {
1050
+ process.stdout.write('[gotry-doctor] 现在配置 FlyAI?控制台取 key → 隐藏粘贴 → 验证后保存(y/N):')
1051
+ const answer = await readOneLine(process.stdin)
1052
+ if (/^y(?:es)?$/i.test(answer.trim())) {
1053
+ const setupCode = await runSetupFlyai()
1054
+ if (setupCode === 130 || setupCode === 143) return setupCode
1055
+ if (setupCode !== 0) say('[gotry-doctor] FlyAI 配置未完成;可重跑 `npx @danceiny/gotry setup flyai`。')
1056
+ } else {
1057
+ say('[gotry-doctor] 已跳过 FlyAI 配置;稍后运行 `npx @danceiny/gotry setup flyai`。')
1079
1058
  }
1080
1059
  }
1060
+ items = await doctorChecks()
1061
+ for (const i of items) {
1062
+ say(` ${doctorIcon[i.level]} ${i.label}:${i.detail}`)
1063
+ if (i.level !== 'ok' && i.fix) say(` ↳ 修复: ${i.fix}`)
1064
+ }
1081
1065
  }
1082
1066
  // 报告落盘(侧栏工作台预览面;写失败不挡体检结论)
1083
1067
  try {
@@ -1157,8 +1141,7 @@ function classifyDoctorGap(item, opts = {}) {
1157
1141
  const autoSupported = doctorFixAutoSupported(platform)
1158
1142
  if (label.startsWith('Agent Reach')) return installerEnabled('reach', env) && autoSupported ? 'auto' : 'unavailable'
1159
1143
  // hbcli:二进制缺失(missing)= auto(setupHbcli 装;win32 → unavailable,上游无 win 安装面);
1160
- // 凭证未配(degraded)= user-action(平台无关);旧版本(missing,安装器不自动升级)= auto 分桶
1161
- // 但安装会失败 → 结果兜底 unavailable,诚实不冒充。
1144
+ // 凭证未配(degraded)= user-action(平台无关);旧版本(missing)= auto,同安装器升级后复检。
1162
1145
  if (label.startsWith('hbcli')) {
1163
1146
  if (item.level === 'degraded') return 'user-action'
1164
1147
  return installerEnabled('hbcli', env) && autoSupported ? 'auto' : 'unavailable'
@@ -1780,7 +1763,7 @@ if (process.argv[1] && process.argv[1].endsWith('gotry-bootstrap.js')) {
1780
1763
  }
1781
1764
 
1782
1765
  export {
1783
- setupSidebar, sidebarInstalled,
1766
+ setupHbcli, setupSidebar, sidebarInstalled,
1784
1767
  classifyDoctorGap, buildOnboardingPlan, onboardingSkipReason,
1785
1768
  runOnboardingFix, promptOnboarding, renderClassifiedPlan, runOnboarding, orchestrateWebLaunch,
1786
1769
  doctorFixAutoSupported, platformAutoUnavailableReason, installerEnabled,
@@ -0,0 +1,12 @@
1
+ export interface ExtensionStatusOptions {
2
+ homeDir?: string
3
+ platform?: NodeJS.Platform
4
+ env?: NodeJS.ProcessEnv
5
+ ports?: readonly number[]
6
+ timeoutMs?: number
7
+ }
8
+ export function checkExtensionStatus(opts?: ExtensionStatusOptions): Promise<{
9
+ status: 'ok' | 'degraded'
10
+ detail: string
11
+ fix?: string
12
+ }>
@@ -0,0 +1,113 @@
1
+ // Shared by the npm CLI and gotry_doctor. Disk presence proves installation,
2
+ // while only a fresh, trusted-origin heartbeat on the existing bridge proves
3
+ // connection. This diagnostic never starts a bridge or sends a heartbeat.
4
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
5
+ import { get } from 'node:http'
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+
9
+ const IDS = ['oeajpiccmonococjcegddlooeeohlbgd', 'olpgkofjhhiiiahdkkbcninhjmegghfe']
10
+ const PORTS = [8791, 8792, 8793, 8794, 8795]
11
+ const STORE_URL = `https://chromewebstore.google.com/detail/gotry-session-bridge/${IDS[0]}`
12
+
13
+ function json(path) {
14
+ try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null }
15
+ }
16
+ function directories(path) {
17
+ try { return readdirSync(path, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name) } catch { return [] }
18
+ }
19
+
20
+ function browserRoots(home, platform, env) {
21
+ if (platform === 'darwin') {
22
+ const base = join(home, 'Library', 'Application Support')
23
+ return ['Google/Chrome', 'Google/Chrome Beta', 'Google/Chrome Canary', 'Chromium', 'Microsoft Edge', 'BraveSoftware/Brave-Browser'].map(path => join(base, path))
24
+ }
25
+ if (platform === 'win32') {
26
+ const base = env.LOCALAPPDATA || join(home, 'AppData', 'Local')
27
+ return ['Google/Chrome/User Data', 'Google/Chrome Beta/User Data', 'Google/Chrome SxS/User Data', 'Chromium/User Data', 'Microsoft/Edge/User Data', 'BraveSoftware/Brave-Browser/User Data'].map(path => join(base, path))
28
+ }
29
+ const base = env.XDG_CONFIG_HOME || join(home, '.config')
30
+ return ['google-chrome', 'google-chrome-beta', 'google-chrome-unstable', 'chromium', 'microsoft-edge', 'BraveSoftware/Brave-Browser'].map(path => join(base, path))
31
+ }
32
+
33
+ function installedProfiles(home, platform, env) {
34
+ const found = []
35
+ for (const root of browserRoots(home, platform, env)) {
36
+ for (const name of directories(root)) {
37
+ const profile = join(root, name)
38
+ const matches = IDS.filter(id => directories(join(profile, 'Extensions', id)).some(version => {
39
+ const manifest = json(join(profile, 'Extensions', id, version, 'manifest.json'))
40
+ return manifest?.manifest_version === 3 && typeof manifest.version === 'string'
41
+ }))
42
+ if (!matches.length) continue
43
+ const secure = json(join(profile, 'Secure Preferences'))?.extensions?.settings
44
+ const preferences = json(join(profile, 'Preferences'))?.extensions?.settings
45
+ for (const id of matches) {
46
+ const settings = secure?.[id] ?? preferences?.[id]
47
+ // Uninstall/update may leave version directories behind. Require the
48
+ // browser's current registration as well as its on-disk manifest.
49
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) continue
50
+ const disabled = (typeof settings?.state === 'number' && settings.state !== 1)
51
+ || (Array.isArray(settings?.disable_reasons) && settings.disable_reasons.length > 0)
52
+ found.push({ profile, disabled })
53
+ }
54
+ }
55
+ }
56
+ return found
57
+ }
58
+
59
+ function bridgeStatus(port, timeoutMs) {
60
+ return new Promise(resolve => {
61
+ let done = false
62
+ const finish = value => { if (!done) { done = true; clearTimeout(timer); resolve(value) } }
63
+ const req = get({ hostname: '127.0.0.1', port, path: '/status' }, res => {
64
+ let body = ''
65
+ res.setEncoding('utf8')
66
+ res.on('data', chunk => {
67
+ body += chunk
68
+ if (body.length > 16_384) { finish(null); req.destroy() }
69
+ })
70
+ res.on('error', () => finish(null))
71
+ res.on('end', () => {
72
+ try {
73
+ const status = JSON.parse(body)
74
+ if (res.statusCode !== 200 || status.ok !== true || status.protocol !== 'session-bridge.v1'
75
+ || typeof status.extensionConnected !== 'boolean') { finish(null); return }
76
+ finish({ port, connected: status.extensionConnected === true
77
+ && Number.isFinite(status.lastSeenMsAgo) && status.lastSeenMsAgo >= 0 && status.lastSeenMsAgo < 45_000 })
78
+ } catch { finish(null) }
79
+ })
80
+ })
81
+ const timer = setTimeout(() => { finish(null); req.destroy() }, timeoutMs)
82
+ req.on('error', () => finish(null))
83
+ })
84
+ }
85
+
86
+ export async function checkExtensionStatus(opts = {}) {
87
+ const home = opts.homeDir ?? homedir()
88
+ const platform = opts.platform ?? process.platform
89
+ const env = opts.env ?? process.env
90
+ const bridges = await Promise.all((opts.ports ?? PORTS).map(port => bridgeStatus(port, opts.timeoutMs ?? 400)))
91
+ const connected = bridges.find(status => status?.connected)
92
+ if (connected) return {
93
+ status: 'ok', detail: `已连接(本机桥端口 ${connected.port},近期扩展心跳已确认)`,
94
+ }
95
+ const profiles = installedProfiles(home, platform, env)
96
+ if (profiles.length) {
97
+ const enabled = profiles.find(profile => !profile.disabled)
98
+ return {
99
+ status: 'degraded',
100
+ detail: enabled ? `已安装(浏览器配置:${enabled.profile});当前连接未确认,离线安装检查不代表会话检索已就绪`
101
+ : `已安装,但已停用(浏览器配置:${profiles[0].profile});当前连接未确认`,
102
+ fix: '打开安装扩展的浏览器,在 chrome://extensions 启用 Stai Travel Bridge;启动 `npx @danceiny/gotry web`,在对话中发起账号会话检索并按提示授权以启动连接;保持会话运行时重跑 doctor,检索以 gotry_session_search 的结果为准',
103
+ }
104
+ }
105
+ if (existsSync(join(home, '.gotry', 'extension', 'manifest.json'))) return {
106
+ status: 'degraded', detail: '本地扩展文件已就位,浏览器加载及当前连接未确认',
107
+ fix: '推荐从商店安装:' + STORE_URL + ';或在 chrome://extensions 加载 ~/.gotry/extension;启动 `npx @danceiny/gotry web`,在对话中发起账号会话检索并按提示授权以启动连接',
108
+ }
109
+ return {
110
+ status: 'degraded', detail: '未检测到浏览器安装记录,当前连接未确认(浏览器可能使用其他配置目录);无法仅凭文件判断安装状态。影响面:账号会话检索需扩展在线,机票 FlyAI、酒店 hbcli、地图、天气等其它工具不受影响',
111
+ fix: '已安装时:打开对应浏览器,在 chrome://extensions 确认已启用;启动 `npx @danceiny/gotry web`,在对话中发起账号会话检索并按提示授权以启动连接;保持会话运行时复检。首次安装:' + STORE_URL,
112
+ }
113
+ }