ot-cli 0.2.1__py3-none-any.whl

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 (91) hide show
  1. openterminal/__init__.py +3 -0
  2. openterminal/agent/__init__.py +39 -0
  3. openterminal/agent/backend.py +180 -0
  4. openterminal/agent/build.py +115 -0
  5. openterminal/agent/middleware.py +111 -0
  6. openterminal/agent/model.py +41 -0
  7. openterminal/agent/prompt.py +63 -0
  8. openterminal/agent/runner.py +340 -0
  9. openterminal/agent/sysprobe.py +164 -0
  10. openterminal/agent/token_est.py +49 -0
  11. openterminal/app.py +52 -0
  12. openterminal/approval.py +7 -0
  13. openterminal/backend.py +3 -0
  14. openterminal/banner.py +7 -0
  15. openterminal/cli.py +426 -0
  16. openterminal/cmdset.py +7 -0
  17. openterminal/config.py +322 -0
  18. openterminal/connections/__init__.py +20 -0
  19. openterminal/connections/db.py +240 -0
  20. openterminal/connections/models.py +289 -0
  21. openterminal/connections/secrets.py +68 -0
  22. openterminal/connections_db.py +6 -0
  23. openterminal/core/__init__.py +41 -0
  24. openterminal/core/ai_flow.py +781 -0
  25. openterminal/core/auth.py +499 -0
  26. openterminal/core/cmdset_runner.py +352 -0
  27. openterminal/core/exec_events.py +650 -0
  28. openterminal/core/history.py +79 -0
  29. openterminal/core/hook_gate.py +415 -0
  30. openterminal/core/messages.py +193 -0
  31. openterminal/core/nested_ssh.py +178 -0
  32. openterminal/core/pipeline.py +1187 -0
  33. openterminal/core/pump.py +190 -0
  34. openterminal/core/transcript.py +26 -0
  35. openterminal/frontend/__init__.py +21 -0
  36. openterminal/frontend/approval.py +40 -0
  37. openterminal/frontend/banner.py +40 -0
  38. openterminal/frontend/headless.py +290 -0
  39. openterminal/frontend/render.py +41 -0
  40. openterminal/frontend/taskview.py +184 -0
  41. openterminal/frontend/term.py +1417 -0
  42. openterminal/headless.py +7 -0
  43. openterminal/history_db.py +7 -0
  44. openterminal/local_pty.py +3 -0
  45. openterminal/local_win.py +3 -0
  46. openterminal/policy.py +362 -0
  47. openterminal/render.py +7 -0
  48. openterminal/secrets_store.py +5 -0
  49. openterminal/session/__init__.py +17 -0
  50. openterminal/session/base.py +328 -0
  51. openterminal/session/local_posix.py +184 -0
  52. openterminal/session/local_win.py +173 -0
  53. openterminal/session/ssh.py +321 -0
  54. openterminal/shell/__init__.py +17 -0
  55. openterminal/shell/cmdset.py +49 -0
  56. openterminal/shell/integration.py +1298 -0
  57. openterminal/shell_integration.py +7 -0
  58. openterminal/shell_session.py +8 -0
  59. openterminal/skills/__init__.py +45 -0
  60. openterminal/skills/markdown-tables/SKILL.md +247 -0
  61. openterminal/ssh_pty.py +6 -0
  62. openterminal/sysprobe.py +7 -0
  63. openterminal/taskview.py +7 -0
  64. openterminal/term_frontend.py +7 -0
  65. openterminal/token_est.py +3 -0
  66. openterminal/transcript.py +7 -0
  67. openterminal/web/__init__.py +1 -0
  68. openterminal/web/files_api.py +789 -0
  69. openterminal/web/frontend/index.html +37 -0
  70. openterminal/web/frontend/static/app.js +3632 -0
  71. openterminal/web/frontend/static/block_stream.js +274 -0
  72. openterminal/web/frontend/static/block_view.js +383 -0
  73. openterminal/web/frontend/static/files_panel.js +782 -0
  74. openterminal/web/frontend/static/md_lite.js +127 -0
  75. openterminal/web/frontend/static/modal.js +122 -0
  76. openterminal/web/frontend/static/pad_ledger.js +46 -0
  77. openterminal/web/frontend/static/style.css +915 -0
  78. openterminal/web/frontend/static/ui/ui.css +1 -0
  79. openterminal/web/frontend/static/ui/ui.js +6292 -0
  80. openterminal/web/frontend/vendor/addon-fit.js +2 -0
  81. openterminal/web/frontend/vendor/xterm.css +8 -0
  82. openterminal/web/frontend/vendor/xterm.js +8 -0
  83. openterminal/web/protocol.py +41 -0
  84. openterminal/web/server.py +322 -0
  85. openterminal/web/worker.py +62 -0
  86. ot_cli-0.2.1.dist-info/METADATA +439 -0
  87. ot_cli-0.2.1.dist-info/RECORD +91 -0
  88. ot_cli-0.2.1.dist-info/WHEEL +5 -0
  89. ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
  90. ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
  91. ot_cli-0.2.1.dist-info/top_level.txt +1 -0
openterminal/config.py ADDED
@@ -0,0 +1,322 @@
1
+ """配置加载:.env(项目 > ~/.openterminal/.env)+ config.toml + 默认值。
2
+
3
+ 模型协议由 .env 的 ANTHROPIC_* / OPENAI_* 二选一决定(都配=报错,都没配=报错);
4
+ config.toml 只管 shell/policy/web/targets 与模型名(credentials 以 .env 为准)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tomllib
11
+ import urllib.error
12
+ import urllib.request
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+
17
+ def app_dir() -> Path:
18
+ return Path(
19
+ os.environ.get("OPENTERMINAL_HOME", str(Path.home() / ".openterminal"))
20
+ )
21
+
22
+
23
+ def _parse_env_file(path: Path) -> dict[str, str]:
24
+ """极简 .env 解析:KEY=VALUE 行、# 注释、可选引号。脏行跳过不打挂。"""
25
+ out: dict[str, str] = {}
26
+ if not path.exists():
27
+ return out
28
+ try:
29
+ text = path.read_text(encoding="utf-8")
30
+ except OSError:
31
+ return out
32
+ for raw in text.splitlines():
33
+ line = raw.strip()
34
+ if not line or line.startswith("#") or "=" not in line:
35
+ continue
36
+ key, _, val = line.partition("=")
37
+ key = key.strip()
38
+ val = val.strip()
39
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
40
+ val = val[1:-1]
41
+ if key:
42
+ out[key] = val
43
+ return out
44
+
45
+
46
+ # 凭据变量名:*_API_KEY 是 SDK 正式名;*_AUTH_TOKEN 是 Anthropic 生态与各类
47
+ # 网关的另一个标准凭据名(Claude Code、Codex 代理、本地免密网关都用它)。
48
+ # 真机踩过:密钥只 export 了 ANTHROPIC_AUTH_TOKEN,旧实现只认 *_API_KEY,
49
+ # 于是判「未配置模型协议」——模型其实一路跑得好好的(红警告误报)。
50
+ _ANTHROPIC_KEY_ENVS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
51
+ _OPENAI_KEY_ENVS = ("OPENAI_API_KEY", "OPENAI_AUTH_TOKEN")
52
+ _ALL_KEY_ENVS = _ANTHROPIC_KEY_ENVS + _OPENAI_KEY_ENVS
53
+
54
+
55
+ def _pick_key(env: dict[str, str], names: tuple[str, ...]) -> str | None:
56
+ """按优先级取第一个有值的凭据变量名;空值不当「配了」。"""
57
+ for n in names:
58
+ if env.get(n):
59
+ return n
60
+ return None
61
+
62
+
63
+ def load_env() -> dict[str, str]:
64
+ """合并 .env:项目 .env(cwd)优先 > ~/.openterminal/.env > 系统环境变量。
65
+
66
+ 「当前项目 .env 优先级最高」(用户约定):同名键项目覆盖家目录、家目录
67
+ 覆盖系统环境。只读不动 os.environ——副作用留给调用方决定。
68
+ """
69
+ # 三层都要看:系统环境变量 < ~/.openterminal/.env < 项目 .env。
70
+ # 旧实现只读两份 .env 文件、漏了系统环境变量——用户把密钥 export 在
71
+ # shell 里时,模型明明跑得好好的却报「未配置模型协议」(真机红警告误报)
72
+ merged: dict[str, str] = {k: v for k, v in os.environ.items() if v}
73
+ home_env = app_dir() / ".env"
74
+ for k, v in _parse_env_file(home_env).items():
75
+ merged[k] = v
76
+ project_env = Path.cwd() / ".env"
77
+ for k, v in _parse_env_file(project_env).items():
78
+ merged[k] = v
79
+ return merged
80
+
81
+
82
+ def model_from_env(env: dict[str, str]) -> tuple[dict, list[str]]:
83
+ """按 ANTHROPIC_*/OPENAI_* 二选一定协议。返回 (model 字段覆盖, 错误列表)。
84
+
85
+ - 两套都配 → 报错(只能配一个);都没配 → 报错(红色警告卡素材);
86
+ - 配了哪套走哪套协议:base_url + 凭据成对才算「配了」,凭据认
87
+ *_API_KEY 与 *_AUTH_TOKEN 两种标准名。
88
+ """
89
+ ant_url = env.get("ANTHROPIC_BASE_URL") or ""
90
+ oai_url = env.get("OPENAI_BASE_URL") or ""
91
+ ant_key_env = _pick_key(env, _ANTHROPIC_KEY_ENVS)
92
+ oai_key_env = _pick_key(env, _OPENAI_KEY_ENVS)
93
+ ant = bool(ant_url) and bool(ant_key_env)
94
+ oai = bool(oai_url) and bool(oai_key_env)
95
+ if ant and oai:
96
+ return {}, ["模型协议冲突:ANTHROPIC_* 与 OPENAI_* 同时配置,只能配一个"]
97
+ if ant:
98
+ return {"provider": "anthropic",
99
+ "base_url": ant_url,
100
+ "api_key": env[ant_key_env],
101
+ "model": env.get("ANTHROPIC_MODEL") or env.get("MODEL_ID", "")}, []
102
+ if oai:
103
+ return {"provider": "openai",
104
+ "base_url": oai_url,
105
+ "api_key": env[oai_key_env],
106
+ "model": env.get("OPENAI_MODEL") or env.get("MODEL_ID", "")}, []
107
+ return {}, ["未配置模型协议:请在项目 .env 或 ~/.openterminal/.env 配置 "
108
+ "ANTHROPIC_BASE_URL+ANTHROPIC_API_KEY 或 "
109
+ "OPENAI_BASE_URL+OPENAI_API_KEY"]
110
+
111
+
112
+ _DEFAULT_MODELS = [
113
+ "claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5",
114
+ ]
115
+
116
+ # provider = "openai"(OpenAI 兼容协议)时的缺省值:模型、下拉可选列表与
117
+ # anthropic 各一套;config.toml [model] 显式写了的字段一律优先
118
+ _DEFAULT_MODEL_OPENAI = "gpt-5"
119
+ _DEFAULT_MODELS_OPENAI = [
120
+ "gpt-5", "gpt-5-mini", "o4-mini",
121
+ ]
122
+
123
+
124
+ def _endpoint_reachable(base_url: str, timeout: float = 0.5) -> bool:
125
+ """生效 base_url 连不连得上:任何 HTTP 响应都算通(404 也说明服务在)。
126
+
127
+ 这是「能跑就不挂红警告」的最终判据——免密网关、异形鉴权(AUTH_TOKEN、
128
+ 网关内定 token)一并覆盖,不用猜用户把密钥写在哪。只发不带凭据的 GET。
129
+ """
130
+ if not base_url:
131
+ return False
132
+ try:
133
+ with urllib.request.urlopen(base_url, timeout=timeout):
134
+ return True
135
+ except urllib.error.HTTPError:
136
+ return True # 服务应答了,只是路径不对——端点是活的
137
+ except Exception:
138
+ return False # 连接拒绝/超时/DNS 挂/URL 脏,都算跑不起来
139
+
140
+
141
+ def _prune_model_errors(errors, env_model, model_cfg, environ):
142
+ r"""去掉「未配置模型协议」的**误报**:模型真能用时不该挂红警告卡。
143
+
144
+ 判据是「跑不跑得起来」,不是「配置文件里有没有那两行」。真机踩过两回:
145
+ ① config.toml [model] 给了 base_url、密钥也在环境变量里,模型跑得好好的,
146
+ 卡头却挂红「未配置模型协议」;
147
+ ② config.toml 压根没写 [model],走内置缺省 base_url(本地网关免密),
148
+ 模型一样跑得好好的——旧判据只认「config.toml 显式写了 base_url」,
149
+ 把「用户没覆盖缺省值」误当成「用户没配置」。
150
+
151
+ 故判据看**最终生效的** ModelConfig,且拿不准就探一下端点可达性。
152
+ 二选一协议的**冲突**报错不在此列,照常报。
153
+ """
154
+ if env_model:
155
+ return list(errors) # .env 配了协议:原样
156
+ key_env = str(model_cfg.api_key_env or "ANTHROPIC_API_KEY")
157
+ # 「能跑」两条路占一条即可:① 凭据到位(显式 api_key / 间接寻址的环境
158
+ # 变量 / *_AUTH_TOKEN 这类网关 token);② 生效端点真连得上(本地网关可能
159
+ # 根本不要密钥,模型照样跑得好好的)
160
+ usable = (
161
+ bool(model_cfg.api_key)
162
+ or bool(environ.get(key_env))
163
+ or any(environ.get(n) for n in _ALL_KEY_ENVS)
164
+ or _endpoint_reachable(model_cfg.base_url)
165
+ )
166
+ if not usable:
167
+ return list(errors)
168
+ out = []
169
+ for e in errors:
170
+ if "未配置模型协议" in e:
171
+ continue
172
+ out.append(e)
173
+ return out
174
+
175
+
176
+ @dataclass
177
+ class ModelConfig:
178
+ provider: str = "anthropic" # anthropic | openai(OpenAI 兼容协议)
179
+ base_url: str = "http://127.0.0.1:15721"
180
+ model: str = "claude-sonnet-4-6"
181
+ api_key_env: str = "ANTHROPIC_API_KEY"
182
+ # .env 直配的密钥(优先于 api_key_env 间接寻址);None=走环境变量
183
+ api_key: str | None = None
184
+ # Web 状态栏模型下拉的可选模型(可在 config.toml [model] models 覆盖)
185
+ models: list[str] = field(default_factory=lambda: list(_DEFAULT_MODELS))
186
+
187
+
188
+ @dataclass
189
+ class ShellConfig:
190
+ timeout_default: int = 120
191
+ max_output_bytes: int = 102_400
192
+ max_tool_turns: int = 10
193
+
194
+
195
+ @dataclass
196
+ class PolicyConfig:
197
+ mode: str = "tiered" # tiered | approve-all | deny-all
198
+ auto_extra: list[str] = field(default_factory=list)
199
+ approve_extra: list[str] = field(default_factory=list)
200
+ deny_extra: list[str] = field(default_factory=list)
201
+
202
+
203
+ @dataclass
204
+ class WebConfig:
205
+ host: str = "127.0.0.1"
206
+ port: int = 8080
207
+ token: str = ""
208
+
209
+
210
+ @dataclass
211
+ class TargetConfig:
212
+ name: str = "default"
213
+ mode: str = "local" # local | ssh
214
+ host: str | None = None
215
+ user: str | None = None
216
+ port: int | None = None
217
+ commands: list[str] = field(default_factory=list) # 连接后自动执行的命令集(原样行,空行/# 注释由 runner 跳过)
218
+
219
+
220
+ @dataclass
221
+ class Config:
222
+ model: ModelConfig = field(default_factory=ModelConfig)
223
+ shell: ShellConfig = field(default_factory=ShellConfig)
224
+ policy: PolicyConfig = field(default_factory=PolicyConfig)
225
+ web: WebConfig = field(default_factory=WebConfig)
226
+ targets: dict[str, TargetConfig] = field(
227
+ default_factory=lambda: {"default": TargetConfig()}
228
+ )
229
+ # 启动期配置错误(协议二选一冲突/都缺):Web 端出红色警告卡展示
230
+ errors: list[str] = field(default_factory=list)
231
+
232
+ @classmethod
233
+ def load(cls, path: Path | None = None) -> "Config":
234
+ path = path or (app_dir() / "config.toml")
235
+ data: dict = {}
236
+ if path.exists():
237
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
238
+
239
+ m, s, p = data.get("model", {}), data.get("shell", {}), data.get("policy", {})
240
+ w = data.get("web", {})
241
+ # 模型协议以 .env 为准(项目 .env > ~/.openterminal/.env > 系统环境);
242
+ # config.toml [model] 只兜底模型名/下拉列表,credentials 不再从它取
243
+ env = load_env()
244
+ env_model, env_errors = model_from_env(env)
245
+ for k, v in env.items():
246
+ if k in _ALL_KEY_ENVS and v:
247
+ os.environ.setdefault(k, v) # api_key_env 间接寻址要用
248
+ # 协议 provider:anthropic(缺省)| openai(OpenAI 兼容协议)。脏值容错:
249
+ # 未知值回落 anthropic,与 port 写坏只丢字段同一哲学——配置手误不
250
+ # 打挂启动。provider 决定 base_url / model / api_key_env / models 的
251
+ # 缺省套(openai 不再继承 anthropic 网关地址),显式字段一律优先。
252
+ provider = str(m.get("provider", "anthropic")).strip().lower()
253
+ if provider not in ("anthropic", "openai"):
254
+ provider = "anthropic"
255
+ openai = provider == "openai"
256
+ targets: dict[str, TargetConfig] = {
257
+ "default": TargetConfig(
258
+ mode=data.get("target", {}).get("default", {}).get("mode", "local")
259
+ )
260
+ }
261
+ for name, t in data.get("target", {}).items():
262
+ if name == "default":
263
+ continue
264
+ # 脏值容错:port 写坏(如 "22abc")只丢该字段,commands 允许
265
+ # TOML 数组或字符串——单个目标的手误不打挂整个启动
266
+ try:
267
+ port = int(t["port"]) if t.get("port") is not None else None
268
+ except (TypeError, ValueError):
269
+ port = None
270
+ raw_cmds = t.get("commands")
271
+ if isinstance(raw_cmds, list):
272
+ commands = [str(c) for c in raw_cmds]
273
+ elif raw_cmds:
274
+ commands = str(raw_cmds).splitlines()
275
+ else:
276
+ commands = []
277
+ targets[name] = TargetConfig(
278
+ name=name,
279
+ mode=t.get("mode", "ssh"),
280
+ host=t.get("host"),
281
+ user=t.get("user"),
282
+ port=port,
283
+ commands=commands,
284
+ )
285
+ model = ModelConfig(
286
+ provider=env_model.get("provider", provider),
287
+ base_url=env_model.get("base_url") or m.get(
288
+ "base_url", "" if openai else ModelConfig.base_url),
289
+ model=env_model.get("model") or m.get(
290
+ "model", _DEFAULT_MODEL_OPENAI if openai else ModelConfig.model),
291
+ api_key_env=m.get(
292
+ "api_key_env",
293
+ ("OPENAI_API_KEY" if env_model.get("provider") == "openai"
294
+ or openai else ModelConfig.api_key_env)),
295
+ models=list(m.get(
296
+ "models",
297
+ _DEFAULT_MODELS_OPENAI if openai else _DEFAULT_MODELS)),
298
+ api_key=env_model.get("api_key"),
299
+ )
300
+ # 降噪必须看**生效的** model,故先构造好再判(旧实现拿 config.toml
301
+ # 的原始 [model] dict 猜,内置缺省 base_url 于是永远算「没配」)
302
+ return cls(
303
+ model=model,
304
+ shell=ShellConfig(
305
+ timeout_default=int(s.get("timeout_default", 120)),
306
+ max_output_bytes=int(s.get("max_output_bytes", 102_400)),
307
+ max_tool_turns=int(s.get("max_tool_turns", 10)),
308
+ ),
309
+ policy=PolicyConfig(
310
+ mode=p.get("mode", "tiered"),
311
+ auto_extra=list(p.get("auto_extra", [])),
312
+ approve_extra=list(p.get("approve_extra", [])),
313
+ deny_extra=list(p.get("deny_extra", [])),
314
+ ),
315
+ web=WebConfig(
316
+ host=w.get("host", WebConfig.host),
317
+ port=int(w.get("port", WebConfig.port)),
318
+ token=w.get("token", WebConfig.token),
319
+ ),
320
+ targets=targets,
321
+ errors=_prune_model_errors(env_errors, env_model, model, os.environ),
322
+ )
@@ -0,0 +1,20 @@
1
+ """connections 域(阶段二 Task 11 重组)。
2
+
3
+ models.py——SSH config 解析、目标列表与记住的连接(会话工厂);
4
+ db.py——记住的连接 SQLite 库;secrets.py——系统凭据库(keyring)封装。
5
+ 本 __init__ 再导出旧 ``connections`` 模块全部公开名,旧 import 路径
6
+ (``from openterminal.connections import ...``)不因拆包而变。
7
+ """
8
+
9
+ from ..config import TargetConfig # noqa: F401 —— 旧模块命名空间原有
10
+ from .db import RenameConflictError # noqa: F401 —— 旧模块统一再导出
11
+ from .models import ( # noqa: F401
12
+ PW_STORE_FAIL_HINT, SshTarget, TargetList, build_target_list,
13
+ cred_alias_eq, credential_referenced, delete_saved_target,
14
+ display_name, find_saved_target, list_ssh_hosts, load_saved_targets,
15
+ open_session, parse_ssh_config, parse_user_at_host, rename_saved_target,
16
+ save_saved_targets, saved_targets_path, upsert_saved_target,
17
+ )
18
+ from .secrets import ( # noqa: F401
19
+ delete_password, load_password, load_password_alias, store_password,
20
+ )
@@ -0,0 +1,240 @@
1
+ """记住的连接存储:SQLite(~/.openterminal/connections.db)。
2
+
3
+ 只存元数据(主机名 / IP / 端口 / 用户名 / 命令集);密码始终走系统
4
+ 凭据库(见 connections/secrets),不落库。首次访问自动从旧 connections.toml
5
+ 一次性迁移,迁移后旧文件改名 connections.toml.migrated 留档。
6
+
7
+ 跳板机已整体下线:旧库的 jumps 表在 connect() 时做一次收尾迁移——
8
+ 逐行清系统凭据库里遗留的跳板机密码后 DROP TABLE;saved.jump 死列
9
+ 尝试 DROP COLUMN(SQLite < 3.35 无此语法时留作墓碑列,SQL 不再引用)。
10
+
11
+ 本模块只操作 dict 行(不 import connections.py,避免循环依赖);
12
+ TargetConfig 的转换在 connections.py 完成。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sqlite3
18
+ from pathlib import Path
19
+
20
+ _SCHEMA = """
21
+ CREATE TABLE IF NOT EXISTS saved(
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ name TEXT NOT NULL UNIQUE,
24
+ host TEXT NOT NULL,
25
+ port INTEGER,
26
+ user TEXT,
27
+ commands TEXT
28
+ );
29
+ """
30
+
31
+
32
+ class RenameConflictError(Exception):
33
+ """编辑连接时新名字撞上另一条既有条目(拒绝写入,数据不动)。"""
34
+
35
+ def __init__(self, name: str) -> None:
36
+ self.name = name
37
+ super().__init__(name)
38
+
39
+
40
+ def _as_port(value) -> int | None:
41
+ try:
42
+ return int(value) if value is not None else None
43
+ except (TypeError, ValueError):
44
+ return None # 脏值不打挂迁移/加载
45
+
46
+
47
+ def db_path() -> Path:
48
+ """数据库位置(跟随 OPENTERMINAL_HOME,用户主目录,不在 git 仓库内)。"""
49
+ from ..config import app_dir
50
+
51
+ return app_dir() / "connections.db"
52
+
53
+
54
+ def connect(path: Path | None = None) -> sqlite3.Connection:
55
+ """打开(必要时创建)数据库并确保 schema + 旧库/旧 TOML 迁移已完成。"""
56
+ path = path or db_path()
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ conn = sqlite3.connect(str(path))
59
+ conn.row_factory = sqlite3.Row
60
+ conn.executescript(_SCHEMA)
61
+ # 旧库升级:saved 补 commands 列(命令集文本;连接后自动执行)
62
+ cols = {r[1] for r in conn.execute("PRAGMA table_info(saved)")}
63
+ if "commands" not in cols:
64
+ conn.execute("ALTER TABLE saved ADD COLUMN commands TEXT")
65
+ _retire_jumps(conn)
66
+ conn.commit()
67
+ _migrate_toml(conn, path)
68
+ return conn
69
+
70
+
71
+ def _sweep_orphan_credentials(conn: sqlite3.Connection,
72
+ rows: list[tuple[str, str | None, int | None]]) -> None:
73
+ """清理已下线跳板机的遗留凭据(尽力而为)。
74
+
75
+ saved 表里还有同址(host/user/port)直连条目的不动——keyring 键是
76
+ display_name 三元组,堡垒机常被同时登记为直连主机,无条件删会把
77
+ 直连记住的密码一起误删(headless 场景直接 exit 68)。
78
+ """
79
+ for host, user, port in rows:
80
+ if not host:
81
+ continue
82
+ shared = conn.execute(
83
+ "SELECT 1 FROM saved WHERE host=? AND user IS ? AND port IS ?",
84
+ (host, user, port)).fetchone()
85
+ if shared:
86
+ continue
87
+ try:
88
+ from .secrets import delete_password
89
+
90
+ delete_password(host, user, port)
91
+ except Exception: # noqa: BLE001 - 凭据库不可用不阻断下线迁移
92
+ pass
93
+
94
+
95
+ def _retire_jumps(conn: sqlite3.Connection) -> None:
96
+ """跳板机下线收尾(幂等):清遗留凭据 → DROP jumps 表 → 删 saved.jump 列。
97
+
98
+ 凭据清理尽力而为(凭据库不可用时跳过该行的删除);表/列删除失败
99
+ (SQLite 过旧)时留作墓碑,SQL 层已不引用 jump,不影响任何读写。
100
+ """
101
+ tables = {r[0] for r in conn.execute(
102
+ "SELECT name FROM sqlite_master WHERE type='table'")}
103
+ if "jumps" in tables:
104
+ rows = [(r["host"], r["user"], r["port"]) for r in conn.execute(
105
+ "SELECT name, host, port, user FROM jumps ORDER BY id")]
106
+ # IF EXISTS:两个进程(ot web + ot CLI)同时首跑迁移时,
107
+ # 后到的 DROP 不再抛 no such table 炸掉调用方
108
+ conn.execute("DROP TABLE IF EXISTS jumps")
109
+ _sweep_orphan_credentials(conn, rows)
110
+ try:
111
+ conn.execute("ALTER TABLE saved DROP COLUMN jump")
112
+ except sqlite3.OperationalError:
113
+ pass # SQLite < 3.35:死列留档,SQL 不再引用
114
+
115
+
116
+ def _migrate_toml(conn: sqlite3.Connection, db_file: Path) -> None:
117
+ """旧 connections.toml 一次性迁移:仅当库为空且旧文件存在时导入。
118
+
119
+ 旧文件里的 [[jumps]] 与 saved.jump 一并忽略(跳板机已下线),但
120
+ jumps 对应的遗留 keyring 凭据顺手清掉(同址直连条目仍在的不动)。"""
121
+ toml_file = db_file.with_name("connections.toml")
122
+ if not toml_file.exists():
123
+ return
124
+ import tomllib
125
+
126
+ try:
127
+ data = tomllib.loads(toml_file.read_text(encoding="utf-8"))
128
+ except Exception: # noqa: BLE001 - 旧文件损坏只跳过迁移,不打挂启动
129
+ return
130
+ jump_rows = [(j.get("host") or "", j.get("user"), _as_port(j.get("port")))
131
+ for j in data.get("jumps", [])]
132
+ if conn.execute("SELECT COUNT(*) FROM saved").fetchone()[0]:
133
+ # 库里已有数据:不重复导入,也不动旧文件;仅做凭据卫生
134
+ _sweep_orphan_credentials(conn, jump_rows)
135
+ return
136
+ for t in data.get("saved", []):
137
+ host = t.get("host") or t.get("name", "")
138
+ if not host:
139
+ continue
140
+ upsert_saved(conn, {
141
+ "name": t.get("name") or host, "host": host,
142
+ "port": _as_port(t.get("port")),
143
+ "user": t.get("user"),
144
+ })
145
+ conn.commit()
146
+ _sweep_orphan_credentials(conn, jump_rows)
147
+ try:
148
+ toml_file.replace(db_file.with_name("connections.toml.migrated"))
149
+ except OSError:
150
+ pass # 改名失败(占用等)不影响数据:库非空后不会重复迁移
151
+
152
+
153
+ def _row_to_dict(row: sqlite3.Row) -> dict:
154
+ return {k: row[k] for k in row.keys()}
155
+
156
+
157
+ # --- saved 表 ---
158
+
159
+ def fetch_saved(conn: sqlite3.Connection) -> list[dict]:
160
+ rows = conn.execute(
161
+ "SELECT name, host, port, user, commands FROM saved ORDER BY id"
162
+ ).fetchall()
163
+ return [_row_to_dict(r) for r in rows]
164
+
165
+
166
+ def replace_saved(conn: sqlite3.Connection, rows: list[dict]) -> None:
167
+ """整表重写(保持既有 save_saved_targets 语义与插入顺序)。"""
168
+ conn.execute("DELETE FROM saved")
169
+ for r in rows:
170
+ conn.execute(
171
+ "INSERT INTO saved(name, host, port, user, commands) "
172
+ "VALUES(?,?,?,?,?)",
173
+ (r.get("name") or r.get("host", ""), r.get("host", ""),
174
+ r.get("port"), r.get("user"), r.get("commands")))
175
+ conn.commit()
176
+
177
+
178
+ def upsert_saved(conn: sqlite3.Connection, row: dict) -> None:
179
+ """按 name 插入或更新(保留原 id 以维持列表顺序)。
180
+
181
+ commands 传 None 保留旧值(不带该字段的旧调用方/旧客户端兼容),
182
+ 传空串显式清空——COALESCE 只兜 None,不兜空串。
183
+ """
184
+ conn.execute(
185
+ "INSERT INTO saved(name, host, port, user, commands) "
186
+ "VALUES(?,?,?,?,?) "
187
+ "ON CONFLICT(name) DO UPDATE SET host=excluded.host, port=excluded.port, "
188
+ "user=excluded.user, "
189
+ "commands=COALESCE(excluded.commands, saved.commands)",
190
+ (row.get("name") or row.get("host", ""), row.get("host", ""),
191
+ row.get("port"), row.get("user"), row.get("commands")))
192
+ conn.commit()
193
+
194
+
195
+ def delete_saved(conn: sqlite3.Connection, name: str) -> dict | None:
196
+ """删除并返回被删行(供调用方清理凭据库);不存在返回 None。"""
197
+ row = conn.execute(
198
+ "SELECT name, host, port, user, commands FROM saved WHERE name=?",
199
+ (name,)).fetchone()
200
+ if row is None:
201
+ return None
202
+ conn.execute("DELETE FROM saved WHERE name=?", (name,))
203
+ conn.commit()
204
+ return _row_to_dict(row)
205
+
206
+
207
+ def rename_saved(conn: sqlite3.Connection, old: str, row: dict) -> dict | None:
208
+ """编辑连接:删旧行、插新行(name 可能改),单事务;返回被删的旧行。
209
+
210
+ - 旧行不存在 → 返回 None,**不写入任何行**(不再凭空建条目);
211
+ - 新名字撞上另一条既有条目 → 抛 RenameConflictError,库不动
212
+ (不再静默合并覆盖别人的条目);
213
+ - commands 传 None 时继承旧行值——这里是先删后插,upsert 的
214
+ COALESCE 兜不住(没有冲突路径可走),必须显式接管;
215
+ - DELETE+INSERT 包在同一事务里,中途失败回滚,不留半截状态。
216
+ """
217
+ r = conn.execute(
218
+ "SELECT name, host, port, user, commands FROM saved WHERE name=?",
219
+ (old,)).fetchone()
220
+ if r is None:
221
+ return None
222
+ deleted = _row_to_dict(r)
223
+ new_name = row.get("name") or row.get("host", "")
224
+ if new_name != old and conn.execute(
225
+ "SELECT 1 FROM saved WHERE name=?", (new_name,)).fetchone():
226
+ raise RenameConflictError(new_name)
227
+ if row.get("commands") is None:
228
+ row = {**row, "commands": deleted.get("commands")}
229
+ try:
230
+ conn.execute("DELETE FROM saved WHERE name=?", (old,))
231
+ conn.execute(
232
+ "INSERT INTO saved(name, host, port, user, commands) "
233
+ "VALUES(?,?,?,?,?)",
234
+ (new_name, row.get("host", ""), row.get("port"),
235
+ row.get("user"), row.get("commands")))
236
+ conn.commit()
237
+ except BaseException:
238
+ conn.rollback()
239
+ raise
240
+ return deleted