leduo-patrol 2.0.1 → 2.2.3

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 (188) hide show
  1. package/README.md +27 -4
  2. package/dist/server/__tests__/access-key-prompt.test.js +80 -0
  3. package/dist/server/__tests__/acp-session.test.js +92 -0
  4. package/dist/server/__tests__/activity-monitor.test.js +13 -1
  5. package/dist/server/__tests__/claude-cli-session.test.js +17 -0
  6. package/dist/server/__tests__/pty-runtime.test.js +28 -0
  7. package/dist/server/__tests__/session-manager.test.js +215 -1
  8. package/dist/server/access-key-prompt.js +84 -0
  9. package/dist/server/acp-session.js +476 -0
  10. package/dist/server/activity-monitor.js +22 -7
  11. package/dist/server/claude-cli-session.js +57 -12
  12. package/dist/server/index.js +104 -21
  13. package/dist/server/launch-mode.js +4 -22
  14. package/dist/server/pty-runtime.js +28 -0
  15. package/dist/server/session-manager.js +1117 -121
  16. package/dist/server/shell-session.js +2 -0
  17. package/dist/server/startup-preferences.js +45 -0
  18. package/dist/web/assets/index-B-YXVUoQ.css +1 -0
  19. package/dist/web/assets/index-Bu0K7QgY.js +21 -0
  20. package/dist/web/index.html +2 -2
  21. package/package.json +3 -1
  22. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/LICENSE +191 -0
  23. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/README.md +53 -0
  24. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.d.ts +823 -0
  25. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.js +965 -0
  26. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.js.map +1 -0
  27. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.d.ts +1 -0
  28. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.js +839 -0
  29. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.js.map +1 -0
  30. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.d.ts +2 -0
  31. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.js +225 -0
  32. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.js.map +1 -0
  33. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.d.ts +2 -0
  34. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.js +130 -0
  35. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.js.map +1 -0
  36. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.d.ts +35 -0
  37. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.js +5 -0
  38. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.js.map +1 -0
  39. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.d.ts +27 -0
  40. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.js +28 -0
  41. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.js.map +1 -0
  42. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.d.ts +2870 -0
  43. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.js +3 -0
  44. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.js.map +1 -0
  45. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.d.ts +5333 -0
  46. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.js +1554 -0
  47. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.js.map +1 -0
  48. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.d.ts +24 -0
  49. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.js +64 -0
  50. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.js.map +1 -0
  51. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/package.json +66 -0
  52. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/schema/schema.json +4125 -0
  53. package/vendor/claude-code-acp/node_modules/@types/node/LICENSE +21 -0
  54. package/vendor/claude-code-acp/node_modules/@types/node/README.md +15 -0
  55. package/vendor/claude-code-acp/node_modules/@types/node/assert/strict.d.ts +105 -0
  56. package/vendor/claude-code-acp/node_modules/@types/node/assert.d.ts +955 -0
  57. package/vendor/claude-code-acp/node_modules/@types/node/async_hooks.d.ts +623 -0
  58. package/vendor/claude-code-acp/node_modules/@types/node/buffer.buffer.d.ts +466 -0
  59. package/vendor/claude-code-acp/node_modules/@types/node/buffer.d.ts +1810 -0
  60. package/vendor/claude-code-acp/node_modules/@types/node/child_process.d.ts +1428 -0
  61. package/vendor/claude-code-acp/node_modules/@types/node/cluster.d.ts +486 -0
  62. package/vendor/claude-code-acp/node_modules/@types/node/compatibility/iterators.d.ts +21 -0
  63. package/vendor/claude-code-acp/node_modules/@types/node/console.d.ts +151 -0
  64. package/vendor/claude-code-acp/node_modules/@types/node/constants.d.ts +20 -0
  65. package/vendor/claude-code-acp/node_modules/@types/node/crypto.d.ts +4065 -0
  66. package/vendor/claude-code-acp/node_modules/@types/node/dgram.d.ts +564 -0
  67. package/vendor/claude-code-acp/node_modules/@types/node/diagnostics_channel.d.ts +576 -0
  68. package/vendor/claude-code-acp/node_modules/@types/node/dns/promises.d.ts +503 -0
  69. package/vendor/claude-code-acp/node_modules/@types/node/dns.d.ts +922 -0
  70. package/vendor/claude-code-acp/node_modules/@types/node/domain.d.ts +166 -0
  71. package/vendor/claude-code-acp/node_modules/@types/node/events.d.ts +1054 -0
  72. package/vendor/claude-code-acp/node_modules/@types/node/fs/promises.d.ts +1329 -0
  73. package/vendor/claude-code-acp/node_modules/@types/node/fs.d.ts +4676 -0
  74. package/vendor/claude-code-acp/node_modules/@types/node/globals.d.ts +150 -0
  75. package/vendor/claude-code-acp/node_modules/@types/node/globals.typedarray.d.ts +101 -0
  76. package/vendor/claude-code-acp/node_modules/@types/node/http.d.ts +2167 -0
  77. package/vendor/claude-code-acp/node_modules/@types/node/http2.d.ts +2480 -0
  78. package/vendor/claude-code-acp/node_modules/@types/node/https.d.ts +405 -0
  79. package/vendor/claude-code-acp/node_modules/@types/node/index.d.ts +115 -0
  80. package/vendor/claude-code-acp/node_modules/@types/node/inspector/promises.d.ts +41 -0
  81. package/vendor/claude-code-acp/node_modules/@types/node/inspector.d.ts +224 -0
  82. package/vendor/claude-code-acp/node_modules/@types/node/inspector.generated.d.ts +4226 -0
  83. package/vendor/claude-code-acp/node_modules/@types/node/module.d.ts +819 -0
  84. package/vendor/claude-code-acp/node_modules/@types/node/net.d.ts +933 -0
  85. package/vendor/claude-code-acp/node_modules/@types/node/os.d.ts +507 -0
  86. package/vendor/claude-code-acp/node_modules/@types/node/package.json +155 -0
  87. package/vendor/claude-code-acp/node_modules/@types/node/path/posix.d.ts +8 -0
  88. package/vendor/claude-code-acp/node_modules/@types/node/path/win32.d.ts +8 -0
  89. package/vendor/claude-code-acp/node_modules/@types/node/path.d.ts +187 -0
  90. package/vendor/claude-code-acp/node_modules/@types/node/perf_hooks.d.ts +643 -0
  91. package/vendor/claude-code-acp/node_modules/@types/node/process.d.ts +2161 -0
  92. package/vendor/claude-code-acp/node_modules/@types/node/punycode.d.ts +117 -0
  93. package/vendor/claude-code-acp/node_modules/@types/node/querystring.d.ts +152 -0
  94. package/vendor/claude-code-acp/node_modules/@types/node/quic.d.ts +910 -0
  95. package/vendor/claude-code-acp/node_modules/@types/node/readline/promises.d.ts +161 -0
  96. package/vendor/claude-code-acp/node_modules/@types/node/readline.d.ts +541 -0
  97. package/vendor/claude-code-acp/node_modules/@types/node/repl.d.ts +415 -0
  98. package/vendor/claude-code-acp/node_modules/@types/node/sea.d.ts +162 -0
  99. package/vendor/claude-code-acp/node_modules/@types/node/sqlite.d.ts +955 -0
  100. package/vendor/claude-code-acp/node_modules/@types/node/stream/consumers.d.ts +38 -0
  101. package/vendor/claude-code-acp/node_modules/@types/node/stream/promises.d.ts +211 -0
  102. package/vendor/claude-code-acp/node_modules/@types/node/stream/web.d.ts +296 -0
  103. package/vendor/claude-code-acp/node_modules/@types/node/stream.d.ts +1760 -0
  104. package/vendor/claude-code-acp/node_modules/@types/node/string_decoder.d.ts +67 -0
  105. package/vendor/claude-code-acp/node_modules/@types/node/test/reporters.d.ts +96 -0
  106. package/vendor/claude-code-acp/node_modules/@types/node/test.d.ts +2240 -0
  107. package/vendor/claude-code-acp/node_modules/@types/node/timers/promises.d.ts +108 -0
  108. package/vendor/claude-code-acp/node_modules/@types/node/timers.d.ts +159 -0
  109. package/vendor/claude-code-acp/node_modules/@types/node/tls.d.ts +1198 -0
  110. package/vendor/claude-code-acp/node_modules/@types/node/trace_events.d.ts +197 -0
  111. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/buffer.buffer.d.ts +462 -0
  112. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/compatibility/float16array.d.ts +71 -0
  113. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/globals.typedarray.d.ts +36 -0
  114. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/index.d.ts +117 -0
  115. package/vendor/claude-code-acp/node_modules/@types/node/ts5.7/compatibility/float16array.d.ts +72 -0
  116. package/vendor/claude-code-acp/node_modules/@types/node/ts5.7/index.d.ts +117 -0
  117. package/vendor/claude-code-acp/node_modules/@types/node/tty.d.ts +250 -0
  118. package/vendor/claude-code-acp/node_modules/@types/node/url.d.ts +519 -0
  119. package/vendor/claude-code-acp/node_modules/@types/node/util/types.d.ts +558 -0
  120. package/vendor/claude-code-acp/node_modules/@types/node/util.d.ts +1662 -0
  121. package/vendor/claude-code-acp/node_modules/@types/node/v8.d.ts +983 -0
  122. package/vendor/claude-code-acp/node_modules/@types/node/vm.d.ts +1208 -0
  123. package/vendor/claude-code-acp/node_modules/@types/node/wasi.d.ts +202 -0
  124. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/abortcontroller.d.ts +59 -0
  125. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/blob.d.ts +23 -0
  126. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/console.d.ts +9 -0
  127. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/crypto.d.ts +39 -0
  128. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/domexception.d.ts +68 -0
  129. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/encoding.d.ts +11 -0
  130. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/events.d.ts +106 -0
  131. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/fetch.d.ts +69 -0
  132. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/importmeta.d.ts +13 -0
  133. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/messaging.d.ts +23 -0
  134. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/navigator.d.ts +25 -0
  135. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/performance.d.ts +45 -0
  136. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/storage.d.ts +24 -0
  137. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/streams.d.ts +115 -0
  138. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/timers.d.ts +44 -0
  139. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/url.d.ts +24 -0
  140. package/vendor/claude-code-acp/node_modules/@types/node/worker_threads.d.ts +717 -0
  141. package/vendor/claude-code-acp/node_modules/@types/node/zlib.d.ts +618 -0
  142. package/vendor/claude-code-acp/node_modules/undici-types/LICENSE +21 -0
  143. package/vendor/claude-code-acp/node_modules/undici-types/README.md +6 -0
  144. package/vendor/claude-code-acp/node_modules/undici-types/agent.d.ts +32 -0
  145. package/vendor/claude-code-acp/node_modules/undici-types/api.d.ts +43 -0
  146. package/vendor/claude-code-acp/node_modules/undici-types/balanced-pool.d.ts +29 -0
  147. package/vendor/claude-code-acp/node_modules/undici-types/cache-interceptor.d.ts +172 -0
  148. package/vendor/claude-code-acp/node_modules/undici-types/cache.d.ts +36 -0
  149. package/vendor/claude-code-acp/node_modules/undici-types/client-stats.d.ts +15 -0
  150. package/vendor/claude-code-acp/node_modules/undici-types/client.d.ts +108 -0
  151. package/vendor/claude-code-acp/node_modules/undici-types/connector.d.ts +34 -0
  152. package/vendor/claude-code-acp/node_modules/undici-types/content-type.d.ts +21 -0
  153. package/vendor/claude-code-acp/node_modules/undici-types/cookies.d.ts +30 -0
  154. package/vendor/claude-code-acp/node_modules/undici-types/diagnostics-channel.d.ts +74 -0
  155. package/vendor/claude-code-acp/node_modules/undici-types/dispatcher.d.ts +276 -0
  156. package/vendor/claude-code-acp/node_modules/undici-types/env-http-proxy-agent.d.ts +22 -0
  157. package/vendor/claude-code-acp/node_modules/undici-types/errors.d.ts +161 -0
  158. package/vendor/claude-code-acp/node_modules/undici-types/eventsource.d.ts +66 -0
  159. package/vendor/claude-code-acp/node_modules/undici-types/fetch.d.ts +211 -0
  160. package/vendor/claude-code-acp/node_modules/undici-types/formdata.d.ts +108 -0
  161. package/vendor/claude-code-acp/node_modules/undici-types/global-dispatcher.d.ts +9 -0
  162. package/vendor/claude-code-acp/node_modules/undici-types/global-origin.d.ts +7 -0
  163. package/vendor/claude-code-acp/node_modules/undici-types/h2c-client.d.ts +73 -0
  164. package/vendor/claude-code-acp/node_modules/undici-types/handlers.d.ts +15 -0
  165. package/vendor/claude-code-acp/node_modules/undici-types/header.d.ts +160 -0
  166. package/vendor/claude-code-acp/node_modules/undici-types/index.d.ts +80 -0
  167. package/vendor/claude-code-acp/node_modules/undici-types/interceptors.d.ts +39 -0
  168. package/vendor/claude-code-acp/node_modules/undici-types/mock-agent.d.ts +68 -0
  169. package/vendor/claude-code-acp/node_modules/undici-types/mock-call-history.d.ts +111 -0
  170. package/vendor/claude-code-acp/node_modules/undici-types/mock-client.d.ts +27 -0
  171. package/vendor/claude-code-acp/node_modules/undici-types/mock-errors.d.ts +12 -0
  172. package/vendor/claude-code-acp/node_modules/undici-types/mock-interceptor.d.ts +94 -0
  173. package/vendor/claude-code-acp/node_modules/undici-types/mock-pool.d.ts +27 -0
  174. package/vendor/claude-code-acp/node_modules/undici-types/package.json +55 -0
  175. package/vendor/claude-code-acp/node_modules/undici-types/patch.d.ts +29 -0
  176. package/vendor/claude-code-acp/node_modules/undici-types/pool-stats.d.ts +19 -0
  177. package/vendor/claude-code-acp/node_modules/undici-types/pool.d.ts +41 -0
  178. package/vendor/claude-code-acp/node_modules/undici-types/proxy-agent.d.ts +29 -0
  179. package/vendor/claude-code-acp/node_modules/undici-types/readable.d.ts +68 -0
  180. package/vendor/claude-code-acp/node_modules/undici-types/retry-agent.d.ts +8 -0
  181. package/vendor/claude-code-acp/node_modules/undici-types/retry-handler.d.ts +125 -0
  182. package/vendor/claude-code-acp/node_modules/undici-types/snapshot-agent.d.ts +109 -0
  183. package/vendor/claude-code-acp/node_modules/undici-types/util.d.ts +18 -0
  184. package/vendor/claude-code-acp/node_modules/undici-types/utility.d.ts +7 -0
  185. package/vendor/claude-code-acp/node_modules/undici-types/webidl.d.ts +341 -0
  186. package/vendor/claude-code-acp/node_modules/undici-types/websocket.d.ts +186 -0
  187. package/dist/web/assets/index-B0sSFjwT.css +0 -1
  188. package/dist/web/assets/index-Cdb0JMLq.js +0 -13
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # 乐多汪汪队 / leduo-patrol
2
2
 
3
- 一个部署在服务器上的 Web 控制台,用来通过 ACP 驱动 Claude Code,并在浏览器里接收执行流、工具调用和权限确认。
3
+ 一个部署在服务器上的 Web 控制台,可在浏览器里用两种方式连接 Claude Code
4
+ - `CLI`:直接内嵌 Claude Code 终端
5
+ - `ACP`:结构化时间线 / 权限 / 提问 / 图片输入视图
6
+
7
+ 两种引擎可在同一会话内切换,并共用同一个 Claude `sessionId`。
4
8
 
5
9
  ## Showcase
6
10
 
@@ -83,7 +87,10 @@
83
87
  - **目录浏览**:创建会话时可在允许根目录范围内浏览子目录,安全限制越权访问
84
88
 
85
89
  ### 工具与集成
86
- - **内置终端**:下方可展开终端抽屉,通过 xterm.js 提供完整 PTY 终端体验(需服务端开启 `LEDUO_ENABLE_SHELL=true`)
90
+ - **双引擎会话**:同一 workspace 会话可在 `CLI` `ACP` 之间切换;切换时复用 Claude `sessionId`
91
+ - **CLI 终端**:主视图可直接运行 Claude Code 原生终端
92
+ - **ACP 结构化视图**:支持时间线、权限确认、AskUserQuestion、多模态图片输入
93
+ - **内置 Shell**:下方可展开终端抽屉,通过 xterm.js 提供完整 PTY shell;服务端默认开启,可通过 `LEDUO_ENABLE_SHELL=false` 显式关闭
87
94
 
88
95
  ### 界面与可访问性
89
96
  - **访问 Key 认证**:所有请求(HTTP / WebSocket)均需携带 key;浏览器检测到无效 key 时展示输入页
@@ -117,6 +124,7 @@ npm run dev:local
117
124
  - `local`:监听 `127.0.0.1`,仅本机可访问
118
125
  - 可通过命令行参数 `--mode=local|server` 或环境变量 `LEDUO_PATROL_BIND_MODE` 指定,命令行参数优先级更高
119
126
  - 若未显式指定,启动时会在终端提示选择模式;并可选择记住到 `~/.leduo-patrol/launch-preferences.json`,后续自动复用
127
+ - 若未设置 `LEDUO_PATROL_ACCESS_KEY`,启动时会提示选择“手动输入自定义 key”或“随机生成 key”,并可选择是否记住到 `~/.leduo-patrol/launch-preferences.json`
120
128
 
121
129
  - 前端开发服务运行在自动探测到的可访问内网 IP(优先 `bond* / eth* / ens* / enp*` 网卡)上
122
130
  - 后端服务运行在 `PORT`(默认 `3001`,端口冲突时会自动递增寻找可用端口)
@@ -150,16 +158,18 @@ LEDUO_PATROL_APP_NAME=乐多汪汪队
150
158
  LEDUO_PATROL_WORKSPACE_PATH=/absolute/workspace/path
151
159
  LEDUO_PATROL_ALLOWED_ROOTS=/absolute/workspace/path,/another/allowed/root
152
160
  LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude
161
+ LEDUO_PATROL_AGENT_BIN=/absolute/path/to/claude-code-acp
153
162
  LEDUO_PATROL_SHELL=/absolute/path/to/zsh
154
163
  ANTHROPIC_API_KEY=sk-...
155
164
  LEDUO_PATROL_ACCESS_KEY=your-fixed-key
156
- LEDUO_ENABLE_SHELL=true
165
+ LEDUO_ENABLE_SHELL=false
157
166
  ```
158
167
 
159
168
  如果设置了 `LEDUO_PATROL_ALLOWED_ROOTS`,网页中只能连接这些根目录之下的路径;未设置时默认只允许启动命令所在目录。
160
169
  如果未设置 `LEDUO_PATROL_WORKSPACE_PATH`,默认工作目录为启动命令所在目录(`process.cwd()`),并在启动日志中提示如何通过环境变量修改。
161
170
  如果未设置 `LEDUO_PATROL_ALLOWED_ROOTS`,默认允许根目录同样为启动命令所在目录,并会在启动日志中提示可配置项。
162
171
  如果发布安装后的内嵌终端无法启动,可通过 `LEDUO_PATROL_SHELL` 显式指定 shell 路径;例如 macOS 上常见的 `/bin/zsh`。
172
+ 如果 ACP agent 不在默认安装位置,可通过 `LEDUO_PATROL_AGENT_BIN` 显式指定 `claude-code-acp` 可执行文件。
163
173
 
164
174
  ## 状态持久化
165
175
 
@@ -177,7 +187,12 @@ LEDUO_ENABLE_SHELL=true
177
187
 
178
188
  ## 访问校验 Key
179
189
 
180
- 服务启动时会自动生成一次性访问 key,并在控制台打印可直接打开的地址。
190
+ 服务启动时会优先按以下顺序确定访问 key,并在控制台打印可直接打开的地址:
191
+
192
+ - `--access-key your-key`
193
+ - `LEDUO_PATROL_ACCESS_KEY`
194
+ - 已记住在 `~/.leduo-patrol/launch-preferences.json` 的 key
195
+ - 若以上都没有:启动时交互选择“手动输入”或“随机生成”
181
196
 
182
197
  - 开发模式(`npm run dev`)下,`Access URL` 默认指向 Web 端口(默认 `5173`)。
183
198
  - 生产模式(`npm start`)下,Web 由同一个 Express 服务静态托管,因此不会出现独立的 Web 监听端口;`Access URL` 会指向 server 端口。若未找到打包后的 `dist/web` 资源,服务会给出错误提示页与启动日志提示。
@@ -193,6 +208,14 @@ LEDUO_PATROL_ACCESS_KEY=your-fixed-key
193
208
  LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude
194
209
  ```
195
210
 
211
+ 也可以直接通过命令行传入:
212
+
213
+ ```bash
214
+ npm run dev:server -- --access-key your-fixed-key
215
+ # 或生产模式
216
+ npm start -- --access-key your-fixed-key
217
+ ```
218
+
196
219
  ## 已知限制
197
220
 
198
221
  - 当前只实现了 Claude Code
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { resolveAccessKey, accessKeyPromptTestables } from "../access-key-prompt.js";
4
+ const NON_TTY_STREAM = {
5
+ isTTY: false,
6
+ };
7
+ const SILENT_STDOUT = {
8
+ isTTY: true,
9
+ write: () => true,
10
+ };
11
+ test("access key prompt normalizes values", () => {
12
+ assert.equal(accessKeyPromptTestables.normalizeAccessKey(" abc "), "abc");
13
+ assert.equal(accessKeyPromptTestables.normalizeAccessKey(""), "");
14
+ assert.equal(accessKeyPromptTestables.normalizeAccessKey(undefined), "");
15
+ });
16
+ test("resolveAccessKey prefers argv over env and remembered key", async () => {
17
+ const result = await resolveAccessKey({
18
+ argv: ["--access-key", "cli-key"],
19
+ envKey: "env-key",
20
+ stdin: NON_TTY_STREAM,
21
+ stdout: SILENT_STDOUT,
22
+ loadRememberedKey: async () => "remembered-key",
23
+ createRandomKey: () => "random-key",
24
+ });
25
+ assert.equal(result, "cli-key");
26
+ });
27
+ test("resolveAccessKey falls back to env key", async () => {
28
+ const result = await resolveAccessKey({
29
+ argv: [],
30
+ envKey: "env-key",
31
+ stdin: NON_TTY_STREAM,
32
+ stdout: SILENT_STDOUT,
33
+ loadRememberedKey: async () => "remembered-key",
34
+ createRandomKey: () => "random-key",
35
+ });
36
+ assert.equal(result, "env-key");
37
+ });
38
+ test("resolveAccessKey falls back to remembered key", async () => {
39
+ const result = await resolveAccessKey({
40
+ argv: [],
41
+ envKey: "",
42
+ stdin: NON_TTY_STREAM,
43
+ stdout: SILENT_STDOUT,
44
+ loadRememberedKey: async () => "remembered-key",
45
+ createRandomKey: () => "random-key",
46
+ });
47
+ assert.equal(result, "remembered-key");
48
+ });
49
+ test("resolveAccessKey generates a random key when no tty prompt is available", async () => {
50
+ const result = await resolveAccessKey({
51
+ argv: [],
52
+ envKey: "",
53
+ stdin: NON_TTY_STREAM,
54
+ stdout: SILENT_STDOUT,
55
+ loadRememberedKey: async () => "",
56
+ createRandomKey: () => "random-key",
57
+ });
58
+ assert.equal(result, "random-key");
59
+ });
60
+ test("resolveAccessKey can save an interactively chosen key", async () => {
61
+ let savedKey = "";
62
+ const ttyStream = {
63
+ isTTY: true,
64
+ };
65
+ const result = await resolveAccessKey({
66
+ argv: [],
67
+ envKey: "",
68
+ stdin: ttyStream,
69
+ stdout: SILENT_STDOUT,
70
+ loadRememberedKey: async () => "",
71
+ saveRememberedKey: async (key) => {
72
+ savedKey = key;
73
+ },
74
+ createRandomKey: () => "random-key",
75
+ promptForAccessKey: async () => "custom-key",
76
+ promptShouldRememberKey: async () => true,
77
+ });
78
+ assert.equal(result, "custom-key");
79
+ assert.equal(savedKey, "custom-key");
80
+ });
@@ -0,0 +1,92 @@
1
+ import test, { mock } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EventEmitter } from "node:events";
4
+ import { PassThrough } from "node:stream";
5
+ import { ClaudeAcpSession, acpSessionTestables } from "../acp-session.js";
6
+ function makeSession() {
7
+ return new ClaudeAcpSession({
8
+ workspacePath: "/tmp/workspace",
9
+ agentBinPath: "claude-code-acp",
10
+ claudeBin: "/tmp/custom-claude",
11
+ onEvent: () => undefined,
12
+ });
13
+ }
14
+ test("ClaudeAcpSession.resolveWorkspacePath allows path within workspace", () => {
15
+ const session = makeSession();
16
+ const resolved = session.resolveWorkspacePath("a/b.txt");
17
+ assert.equal(resolved, "/tmp/workspace/a/b.txt");
18
+ });
19
+ test("ClaudeAcpSession.resolveWorkspacePath rejects traversal", () => {
20
+ const session = makeSession();
21
+ assert.throws(() => session.resolveWorkspacePath("../etc/passwd"), /outside workspace/);
22
+ });
23
+ test("ClaudeAcpSession.resolvePermission rejects unknown request", async () => {
24
+ const session = makeSession();
25
+ await assert.rejects(() => session.resolvePermission("missing", "allow"), /not found|already resolved/);
26
+ });
27
+ test("ClaudeAcpSession.answerQuestion resolves pending question", async () => {
28
+ const session = makeSession();
29
+ const calls = [];
30
+ session.pendingQuestions.set("q-1", {
31
+ resolve: (value) => calls.push(value),
32
+ reject: () => undefined,
33
+ });
34
+ await session.answerQuestion("q-1", "好的");
35
+ assert.deepEqual(calls[0], { answer: "好的" });
36
+ assert.equal(session.pendingQuestions.size, 0);
37
+ });
38
+ test("ClaudeAcpSession.handleExtMethod routes leduo/ask_question", async () => {
39
+ const events = [];
40
+ const session = new ClaudeAcpSession({
41
+ workspacePath: "/tmp/workspace",
42
+ agentBinPath: "claude-code-acp",
43
+ onEvent: (event) => events.push(event),
44
+ });
45
+ const resultPromise = session.handleExtMethod("leduo/ask_question", {
46
+ question: "选择颜色",
47
+ options: [{ id: "red", label: "红色" }],
48
+ allowCustomAnswer: true,
49
+ });
50
+ const event = events[0];
51
+ assert.equal(event.type, "question_requested");
52
+ await session.answerQuestion(event.payload.questionId, "红色");
53
+ const result = await resultPromise;
54
+ assert.deepEqual(result, { answer: "红色" });
55
+ });
56
+ test("ClaudeAcpSession.findRestorableSession requires exact match when preferred id is provided", async () => {
57
+ const session = makeSession();
58
+ session.connection = {
59
+ unstable_listSessions: async () => ({
60
+ sessions: [{ sessionId: "other-session-id" }],
61
+ }),
62
+ };
63
+ const result = await session.findRestorableSession("missing-session-id");
64
+ assert.equal(result, null);
65
+ });
66
+ test("ClaudeAcpSession.findRestorableSession falls back to the first session when no preferred id is provided", async () => {
67
+ const session = makeSession();
68
+ session.connection = {
69
+ unstable_listSessions: async () => ({
70
+ sessions: [{ sessionId: "restorable-session-id" }],
71
+ }),
72
+ };
73
+ const result = await session.findRestorableSession();
74
+ assert.equal(result, "restorable-session-id");
75
+ });
76
+ test("ClaudeAcpSession.connect rejects gracefully when the ACP agent spawn emits EAGAIN", async (t) => {
77
+ const fakeChild = new EventEmitter();
78
+ fakeChild.stdin = new PassThrough();
79
+ fakeChild.stdout = new PassThrough();
80
+ fakeChild.stderr = new PassThrough();
81
+ fakeChild.kill = (() => true);
82
+ const spawnMock = mock.method(acpSessionTestables, "spawnAgent", () => {
83
+ queueMicrotask(() => {
84
+ const error = Object.assign(new Error("resource temporarily unavailable"), { code: "EAGAIN" });
85
+ fakeChild.emit("error", error);
86
+ });
87
+ return fakeChild;
88
+ });
89
+ t.after(() => spawnMock.mock.restore());
90
+ const session = makeSession();
91
+ await assert.rejects(() => session.connect(), /Failed to start Claude ACP agent.*EAGAIN/);
92
+ });
@@ -8,12 +8,24 @@ test("assistant with stop_reason null → running", () => {
8
8
  message: { stop_reason: null, content: [{ type: "thinking" }] },
9
9
  }), "running");
10
10
  });
11
- test("assistant with stop_reason undefined → running", () => {
11
+ test("assistant with text content and stop_reason undefined → completed", () => {
12
12
  assert.equal(determineActivityState({
13
13
  type: "assistant",
14
14
  message: { content: [{ type: "text" }] },
15
+ }), "completed");
16
+ });
17
+ test("assistant with thinking content and stop_reason undefined → running", () => {
18
+ assert.equal(determineActivityState({
19
+ type: "assistant",
20
+ message: { content: [{ type: "thinking" }] },
15
21
  }), "running");
16
22
  });
23
+ test("assistant with tool_use content and stop_reason undefined → pending", () => {
24
+ assert.equal(determineActivityState({
25
+ type: "assistant",
26
+ message: { content: [{ type: "tool_use" }] },
27
+ }), "pending");
28
+ });
17
29
  test("assistant with no message field → running", () => {
18
30
  assert.equal(determineActivityState({ type: "assistant" }), "running");
19
31
  });
@@ -22,3 +22,20 @@ test("claudeCliSessionTestables.resolveClaudeBin accepts explicit executable pat
22
22
  test("claudeCliSessionTestables.resolveClaudeBin throws actionable error when claude is missing", () => {
23
23
  assert.throws(() => claudeCliSessionTestables.resolveClaudeBin(undefined, { PATH: "" }), /LEDUO_PATROL_CLAUDE_BIN/);
24
24
  });
25
+ test("claudeCliSessionTestables.buildShellWrappedClaudeLaunch uses a shell exec wrapper", () => {
26
+ const launch = claudeCliSessionTestables.buildShellWrappedClaudeLaunch("/opt/claude/bin/claude", ["--session-id", "session-123"], (candidate) => candidate === "/bin/sh");
27
+ assert.deepEqual(launch, {
28
+ command: "/bin/sh",
29
+ args: ["-c", 'exec "$0" "$@"', "/opt/claude/bin/claude", "--session-id", "session-123"],
30
+ });
31
+ });
32
+ test("claudeCliSessionTestables.shouldRetryClaudeSpawnWithShell matches posix_spawnp failures", () => {
33
+ const shouldRetry = claudeCliSessionTestables.shouldRetryClaudeSpawnWithShell(new Error("posix_spawnp failed."));
34
+ assert.equal(typeof shouldRetry, "boolean");
35
+ if (process.platform === "win32") {
36
+ assert.equal(shouldRetry, false);
37
+ }
38
+ else {
39
+ assert.equal(shouldRetry, true);
40
+ }
41
+ });
@@ -0,0 +1,28 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { mkdtempSync, statSync, writeFileSync, chmodSync } from "node:fs";
6
+ import { ensureExecutableBit } from "../pty-runtime.js";
7
+ test("ensureExecutableBit adds execute permissions when missing", () => {
8
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-pty-runtime-"));
9
+ const helperPath = path.join(tempDir, "spawn-helper");
10
+ writeFileSync(helperPath, "#!/bin/sh\n");
11
+ chmodSync(helperPath, 0o644);
12
+ const beforeMode = statSync(helperPath).mode & 0o777;
13
+ const changed = ensureExecutableBit(helperPath);
14
+ const afterMode = statSync(helperPath).mode & 0o777;
15
+ assert.equal(beforeMode, 0o644);
16
+ assert.equal(changed, true);
17
+ assert.equal(afterMode, 0o755);
18
+ });
19
+ test("ensureExecutableBit is a no-op when execute permissions already exist", () => {
20
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-pty-runtime-"));
21
+ const helperPath = path.join(tempDir, "spawn-helper");
22
+ writeFileSync(helperPath, "#!/bin/sh\n");
23
+ chmodSync(helperPath, 0o755);
24
+ const changed = ensureExecutableBit(helperPath);
25
+ const mode = statSync(helperPath).mode & 0o777;
26
+ assert.equal(changed, false);
27
+ assert.equal(mode, 0o755);
28
+ });
@@ -1,7 +1,221 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { sessionManagerTestables } from "../session-manager.js";
3
+ import { SessionManager, sessionManagerTestables } from "../session-manager.js";
4
+ function makeSnapshot(overrides = {}) {
5
+ return {
6
+ clientSessionId: "s1",
7
+ title: "demo",
8
+ workspacePath: process.cwd(),
9
+ connectionState: "connected",
10
+ activityState: "idle",
11
+ sessionId: "shared-session-id",
12
+ engine: "cli",
13
+ switchable: true,
14
+ updatedAt: new Date().toISOString(),
15
+ ...overrides,
16
+ };
17
+ }
18
+ function makeEntry(overrides = {}) {
19
+ return {
20
+ snapshot: makeSnapshot(overrides),
21
+ cliSession: null,
22
+ cliExitExpected: false,
23
+ acpSession: null,
24
+ acpFullTimeline: [],
25
+ outputBuffer: "",
26
+ switchInProgress: false,
27
+ };
28
+ }
29
+ function makeManager(options) {
30
+ const manager = new SessionManager(options);
31
+ manager.activityMonitor.watch = () => undefined;
32
+ manager.activityMonitor.unwatch = () => undefined;
33
+ return manager;
34
+ }
4
35
  test("sessionManagerTestables.formatError handles Error and objects", () => {
5
36
  assert.equal(sessionManagerTestables.formatError(new Error("boom")), "boom");
6
37
  assert.match(sessionManagerTestables.formatError({ code: 1 }), /"code":1/);
7
38
  });
39
+ test("SessionManager.getAvailableEngines exposes ACP only when agent path exists", () => {
40
+ const cliOnly = makeManager({ allowedRoots: [process.cwd()] });
41
+ const dual = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
42
+ assert.deepEqual(cliOnly.getAvailableEngines(), ["cli"]);
43
+ assert.deepEqual(dual.getAvailableEngines(), ["cli", "acp"]);
44
+ });
45
+ test("SessionManager.switchEngine keeps the same Claude sessionId", async () => {
46
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
47
+ const started = [];
48
+ const stopped = [];
49
+ const events = [];
50
+ manager.startEngine = async (entry, resume) => {
51
+ started.push({ engine: entry.snapshot.engine, resume, sessionId: entry.snapshot.sessionId });
52
+ entry.snapshot.connectionState = "connected";
53
+ };
54
+ manager.stopEngine = async (entry) => {
55
+ stopped.push(entry.snapshot.engine);
56
+ };
57
+ manager.subscribe((event) => events.push(event.type));
58
+ manager.sessions.set("s1", makeEntry());
59
+ await manager.switchEngine("s1", "acp");
60
+ const entry = manager.sessions.get("s1");
61
+ assert.equal(entry.snapshot.engine, "acp");
62
+ assert.equal(entry.snapshot.sessionId, "shared-session-id");
63
+ assert.deepEqual(stopped, ["cli"]);
64
+ assert.deepEqual(started, [{ engine: "acp", resume: true, sessionId: "shared-session-id" }]);
65
+ assert.equal(events.at(-1), "session_updated");
66
+ });
67
+ test("SessionManager.switchEngine clears buffered CLI output before starting the target engine", async () => {
68
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
69
+ manager.sessions.set("s1", {
70
+ ...makeEntry({ engine: "acp" }),
71
+ outputBuffer: "old cli output",
72
+ });
73
+ manager.startEngine = async (entry, resume) => {
74
+ assert.equal(entry.outputBuffer, "");
75
+ assert.equal(resume, true);
76
+ entry.snapshot.connectionState = "connected";
77
+ };
78
+ manager.stopEngine = SessionManager.prototype["stopEngine"].bind(manager);
79
+ await manager.switchEngine("s1", "cli");
80
+ const entry = manager.sessions.get("s1");
81
+ assert.equal(entry.outputBuffer, "");
82
+ });
83
+ test("SessionManager.createSession starts fresh CLI sessions and emits a connected snapshot", async () => {
84
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
85
+ const started = [];
86
+ const events = [];
87
+ manager.resolveRequestedWorkspace = async (requestedWorkspacePath) => requestedWorkspacePath;
88
+ manager.startEngine = async (entry, resume) => {
89
+ started.push({ engine: entry.snapshot.engine, resume, sessionId: entry.snapshot.sessionId });
90
+ entry.snapshot.connectionState = "connected";
91
+ };
92
+ manager.subscribe((event) => {
93
+ if (event.type === "session_registered" || event.type === "session_updated") {
94
+ events.push(event);
95
+ }
96
+ });
97
+ const snapshot = await manager.createSession("/tmp/fresh-cli-workspace", "fresh-cli", false, "cli");
98
+ assert.equal(snapshot.engine, "cli");
99
+ assert.ok(snapshot.sessionId);
100
+ assert.deepEqual(started, [{ engine: "cli", resume: false, sessionId: snapshot.sessionId }]);
101
+ assert.deepEqual(events.map((event) => event.type), ["session_registered", "session_updated"]);
102
+ assert.equal(events[0]?.payload.connectionState, "connecting");
103
+ assert.equal(events[1]?.payload.connectionState, "connected");
104
+ });
105
+ test("SessionManager.createSession emits an error snapshot when startup fails", async () => {
106
+ const manager = makeManager({ allowedRoots: [process.cwd()] });
107
+ const events = [];
108
+ manager.resolveRequestedWorkspace = async (requestedWorkspacePath) => requestedWorkspacePath;
109
+ manager.startEngine = async (entry) => {
110
+ entry.snapshot.connectionState = "error";
111
+ throw new Error("boom");
112
+ };
113
+ manager.subscribe((event) => {
114
+ if (event.type === "session_registered" || event.type === "session_updated") {
115
+ events.push(event);
116
+ }
117
+ });
118
+ await assert.rejects(() => manager.createSession("/tmp/broken-cli-workspace", "broken-cli", false, "cli"), /boom/);
119
+ assert.deepEqual(events.map((event) => event.type), ["session_registered", "session_updated"]);
120
+ assert.equal(events[0]?.payload.connectionState, "connecting");
121
+ assert.equal(events[1]?.payload.connectionState, "error");
122
+ });
123
+ test("SessionManager.switchEngine rejects busy sessions", async () => {
124
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
125
+ manager.sessions.set("s1", makeEntry({ activityState: "running" }));
126
+ manager.startEngine = async () => undefined;
127
+ manager.stopEngine = async () => undefined;
128
+ await assert.rejects(() => manager.switchEngine("s1", "acp"), /Session is not switchable: 运行中/);
129
+ });
130
+ test("SessionManager.switchEngine rejects pending ACP approvals", async () => {
131
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
132
+ manager.sessions.set("s1", makeEntry({
133
+ engine: "acp",
134
+ acp: {
135
+ modes: ["default"],
136
+ defaultModeId: "default",
137
+ currentModeId: "default",
138
+ busy: false,
139
+ timeline: [],
140
+ historyTotal: 0,
141
+ historyStart: 0,
142
+ permissions: [{
143
+ clientSessionId: "s1",
144
+ requestId: "req-1",
145
+ toolCall: { toolCallId: "tool-1", title: "Write" },
146
+ options: [{ optionId: "allow", name: "允许", kind: "allow" }],
147
+ }],
148
+ questions: [],
149
+ availableCommands: [],
150
+ },
151
+ }));
152
+ manager.startEngine = async () => undefined;
153
+ manager.stopEngine = async () => undefined;
154
+ await assert.rejects(() => manager.switchEngine("s1", "cli"), /Session is not switchable: 待审批/);
155
+ });
156
+ test("SessionManager.switchEngine allows idle ACP sessions despite stale activityState", async () => {
157
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
158
+ const started = [];
159
+ const stopped = [];
160
+ manager.sessions.set("s1", makeEntry({
161
+ engine: "acp",
162
+ activityState: "running",
163
+ acp: {
164
+ modes: ["default"],
165
+ defaultModeId: "default",
166
+ currentModeId: "default",
167
+ busy: false,
168
+ timeline: [],
169
+ historyTotal: 0,
170
+ historyStart: 0,
171
+ permissions: [],
172
+ questions: [],
173
+ availableCommands: [],
174
+ },
175
+ }));
176
+ manager.startEngine = async (entry, resume) => {
177
+ started.push({ engine: entry.snapshot.engine, resume });
178
+ entry.snapshot.connectionState = "connected";
179
+ };
180
+ manager.stopEngine = async (entry) => {
181
+ stopped.push(entry.snapshot.engine);
182
+ };
183
+ await manager.switchEngine("s1", "cli");
184
+ assert.deepEqual(stopped, ["acp"]);
185
+ assert.deepEqual(started, [{ engine: "cli", resume: true }]);
186
+ });
187
+ test("SessionManager.switchEngine allows ACP sessions after end_turn even if busy flag is stale", async () => {
188
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
189
+ const started = [];
190
+ const stopped = [];
191
+ manager.sessions.set("s1", makeEntry({
192
+ engine: "acp",
193
+ acp: {
194
+ modes: ["default"],
195
+ defaultModeId: "default",
196
+ currentModeId: "default",
197
+ busy: true,
198
+ timeline: [{
199
+ id: "done-1",
200
+ kind: "system",
201
+ title: "本轮完成",
202
+ body: "end_turn",
203
+ }],
204
+ historyTotal: 1,
205
+ historyStart: 0,
206
+ permissions: [],
207
+ questions: [],
208
+ availableCommands: [],
209
+ },
210
+ }));
211
+ manager.startEngine = async (entry, resume) => {
212
+ started.push({ engine: entry.snapshot.engine, resume });
213
+ entry.snapshot.connectionState = "connected";
214
+ };
215
+ manager.stopEngine = async (entry) => {
216
+ stopped.push(entry.snapshot.engine);
217
+ };
218
+ await manager.switchEngine("s1", "cli");
219
+ assert.deepEqual(stopped, ["acp"]);
220
+ assert.deepEqual(started, [{ engine: "cli", resume: true }]);
221
+ });
@@ -0,0 +1,84 @@
1
+ import readline from "node:readline/promises";
2
+ import { createAccessKey } from "./access-key.js";
3
+ import { loadStartupPreferences, saveStartupPreferences } from "./startup-preferences.js";
4
+ import { readOptionValue } from "./launch-mode.js";
5
+ export async function resolveAccessKey(options = {}) {
6
+ const argv = options.argv ?? process.argv.slice(2);
7
+ const stdin = options.stdin ?? process.stdin;
8
+ const stdout = options.stdout ?? process.stdout;
9
+ const createRandomKey = options.createRandomKey ?? createAccessKey;
10
+ const loadRememberedKey = options.loadRememberedKey ?? loadRememberedAccessKey;
11
+ const saveRememberedKey = options.saveRememberedKey ?? saveRememberedAccessKey;
12
+ const promptForKey = options.promptForAccessKey ?? promptForAccessKey;
13
+ const promptShouldRemember = options.promptShouldRememberKey ?? promptShouldRememberKey;
14
+ const argvKey = normalizeAccessKey(readOptionValue(argv, "--access-key"));
15
+ if (argvKey) {
16
+ return argvKey;
17
+ }
18
+ const envKey = normalizeAccessKey(options.envKey ?? process.env.LEDUO_PATROL_ACCESS_KEY);
19
+ if (envKey) {
20
+ return envKey;
21
+ }
22
+ const rememberedKey = normalizeAccessKey(await loadRememberedKey());
23
+ if (rememberedKey) {
24
+ return rememberedKey;
25
+ }
26
+ const generatedKey = createRandomKey();
27
+ if (!stdin.isTTY || !stdout.isTTY) {
28
+ return generatedKey;
29
+ }
30
+ const selectedKey = await promptForKey(stdin, stdout, generatedKey);
31
+ const shouldRemember = await promptShouldRemember(stdin, stdout);
32
+ if (shouldRemember) {
33
+ await saveRememberedKey(selectedKey);
34
+ stdout.write("已记住访问 key,后续启动会自动复用。\n");
35
+ }
36
+ return selectedKey;
37
+ }
38
+ async function promptForAccessKey(stdin, stdout, generatedKey) {
39
+ const rl = readline.createInterface({ input: stdin, output: stdout });
40
+ try {
41
+ stdout.write("\n请选择访问 key 生成方式:\n");
42
+ stdout.write(" 1) 手动输入自定义 key\n");
43
+ stdout.write(" 2) 使用随机生成 key\n");
44
+ const answer = (await rl.question("输入 1/2,默认 2: ")).trim().toLowerCase();
45
+ if (answer === "1" || answer === "custom" || answer === "manual") {
46
+ const customAnswer = (await rl.question("请输入自定义访问 key: ")).trim();
47
+ const customKey = normalizeAccessKey(customAnswer);
48
+ if (customKey) {
49
+ return customKey;
50
+ }
51
+ stdout.write("未输入有效 key,已改用随机生成 key。\n");
52
+ }
53
+ stdout.write(`本次启动使用随机 key: ${generatedKey}\n`);
54
+ return generatedKey;
55
+ }
56
+ finally {
57
+ rl.close();
58
+ }
59
+ }
60
+ async function promptShouldRememberKey(stdin, stdout) {
61
+ const rl = readline.createInterface({ input: stdin, output: stdout });
62
+ try {
63
+ const answer = (await rl.question("是否记住此访问 key 用于后续启动?(y/N): ")).trim().toLowerCase();
64
+ return answer === "y" || answer === "yes";
65
+ }
66
+ finally {
67
+ rl.close();
68
+ }
69
+ }
70
+ function normalizeAccessKey(raw) {
71
+ const normalized = raw?.trim() ?? "";
72
+ return normalized || "";
73
+ }
74
+ async function loadRememberedAccessKey() {
75
+ return normalizeAccessKey((await loadStartupPreferences()).accessKey);
76
+ }
77
+ async function saveRememberedAccessKey(key) {
78
+ await saveStartupPreferences({ accessKey: key });
79
+ }
80
+ export const accessKeyPromptTestables = {
81
+ normalizeAccessKey,
82
+ loadRememberedAccessKey,
83
+ saveRememberedAccessKey,
84
+ };