devmate 1.0.0__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.
@@ -0,0 +1,181 @@
1
+ """
2
+ scaffold — 项目脚手架
3
+
4
+ 基于内置模板快速创建项目,管理代码片段。
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from devmate.bus.command import CommandResult
11
+
12
+ # ── 内置模板 ────────────────────────────────────────
13
+
14
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
15
+
16
+
17
+ _TEMPLATES: dict[str, dict] = {}
18
+
19
+
20
+ def _ensure_templates():
21
+ """确保模板文件存在"""
22
+ global _TEMPLATES
23
+ _TEMPLATES = {
24
+ "python-cli": {
25
+ "description": "Python CLI 项目(click + rich + pytest)",
26
+ "files": {
27
+ "README.md": "# {name}\n\n## 安装\n\npip install -e .\n",
28
+ "pyproject.toml": """[project]
29
+ name = "{name}"
30
+ version = "0.1.0"
31
+ description = "{description}"
32
+ requires-python = ">=3.11"
33
+ dependencies = ["click>=8.1", "rich>=13.0"]
34
+
35
+ [project.scripts]
36
+ {name} = "{name}.cli:main"
37
+
38
+ [build-system]
39
+ requires = ["setuptools>=68"]
40
+ build-backend = "setuptools.build_meta"
41
+ """,
42
+ "src/{name}/__init__.py": '""" {name} """\n\n__version__ = "0.1.0"\n',
43
+ "src/{name}/cli.py": """import click\n\n@click.command()\ndef main(): # noqa: E501\n click.echo("Hello from {name}!")\n
44
+ """, # noqa: E501
45
+ "tests/test_cli.py": """from click.testing import CliRunner
46
+ from {name}.cli import main
47
+
48
+ def test_cli():
49
+ runner = CliRunner()
50
+ result = runner.invoke(main, ["--help"])
51
+ assert result.exit_code == 0
52
+ """,
53
+ },
54
+ },
55
+ "fastapi": {
56
+ "description": "FastAPI Web 项目",
57
+ "files": {
58
+ "README.md": "# {name}\n\nFastAPI 项目\n",
59
+ "requirements.txt": "fastapi>=0.100\nuvicorn>=0.20\n",
60
+ "main.py": """from fastapi import FastAPI
61
+
62
+ app = FastAPI(title="{name}")
63
+
64
+ @app.get("/")
65
+ async def root():
66
+ return {{"message": "Hello from {name}"}}
67
+ """,
68
+ ".gitignore": "__pycache__/\n.venv/\nenv/\n*.pyc\n.DS_Store\n",
69
+ },
70
+ },
71
+ "react": {
72
+ "description": "React 前端项目",
73
+ "files": {
74
+ "README.md": "# {name}\n\nReact 项目\n",
75
+ "package.json": """{{
76
+ "name": "{name}",
77
+ "version": "1.0.0",
78
+ "scripts": {{
79
+ "dev": "vite",
80
+ "build": "vite build"
81
+ }}
82
+ }}
83
+ """,
84
+ "index.html": "<!DOCTYPE html>\n<html><head><title>{name}</title></head><body>\n<div id=\"root\"></div>\n<script type=\"module\" src=\"/src/main.jsx\"></script>\n</body></html>\n", # noqa: E501
85
+ "src/main.jsx": 'import React from "react";\nimport ReactDOM from "react-dom/client";\n\nfunction App() {{\n return <h1>{name}</h1>;\n}}\n\nReactDOM.createRoot(document.getElementById("root")).render(<App />);\n', # noqa: E501
86
+ },
87
+ },
88
+ }
89
+
90
+ for name, spec in _TEMPLATES.items():
91
+ tdir = TEMPLATES_DIR / name
92
+ tdir.mkdir(parents=True, exist_ok=True)
93
+ (tdir / "meta.json").write_text(json.dumps({
94
+ "name": name, "description": spec["description"],
95
+ }, indent=2))
96
+
97
+
98
+ def _list_templates() -> list[dict]:
99
+ """列出内置模板"""
100
+ _ensure_templates()
101
+ return [{"name": k, "description": v.get("description", "")}
102
+ for k, v in _TEMPLATES.items()]
103
+
104
+
105
+ def new_project(template: str, name: str, output: str = "",
106
+ description: str = "") -> CommandResult:
107
+ """基于模板创建新项目"""
108
+ if template not in _TEMPLATES:
109
+ available = ", ".join(_TEMPLATES.keys())
110
+ return CommandResult(success=False, error=f"模板不存在: {template}。可用: {available}")
111
+
112
+ meta = _TEMPLATES[template]
113
+ out_path = Path(output).expanduser().resolve() if output else Path.cwd() / name
114
+
115
+ if out_path.exists():
116
+ return CommandResult(success=False, error=f"目录已存在: {out_path}")
117
+
118
+ out_path.mkdir(parents=True, exist_ok=True)
119
+ created_files = []
120
+
121
+ for file_rel, content in meta.get("files", {}).items():
122
+ file_path = out_path / file_rel.format(name=name)
123
+ file_path.parent.mkdir(parents=True, exist_ok=True)
124
+ file_content = content.format(name=name, description=description or meta.get("description", "")) # noqa: E501
125
+ file_path.write_text(file_content, encoding="utf-8")
126
+ created_files.append(str(file_path))
127
+
128
+ return CommandResult(success=True, data={
129
+ "message": f"✅ 项目已创建: {out_path}",
130
+ "path": str(out_path),
131
+ "files": created_files,
132
+ "template": template,
133
+ })
134
+
135
+
136
+ # ── 代码片段管理 ────────────────────────────────────
137
+
138
+ SNIPPETS_FILE = Path.home() / ".devmate" / "snippets.json"
139
+
140
+
141
+ def snippet_list() -> CommandResult:
142
+ """列出代码片段"""
143
+ if not SNIPPETS_FILE.exists():
144
+ return CommandResult(success=True, data={"snippets": []})
145
+ snippets = json.loads(SNIPPETS_FILE.read_text())
146
+ return CommandResult(success=True, data={"snippets": snippets})
147
+
148
+
149
+ def snippet_add(name: str, code: str, lang: str = "",
150
+ desc: str = "") -> CommandResult:
151
+ """添加代码片段"""
152
+ SNIPPETS_FILE.parent.mkdir(parents=True, exist_ok=True)
153
+ snippets = []
154
+ if SNIPPETS_FILE.exists():
155
+ snippets = json.loads(SNIPPETS_FILE.read_text())
156
+
157
+ snippets = [s for s in snippets if s.get("name") != name]
158
+ snippets.append({
159
+ "name": name, "code": code, "lang": lang, "desc": desc,
160
+ "created": time.strftime("%Y-%m-%d %H:%M"),
161
+ })
162
+ SNIPPETS_FILE.write_text(json.dumps(snippets, indent=2, ensure_ascii=False))
163
+ return CommandResult(success=True, data={"message": f"已添加片段: {name}"})
164
+
165
+
166
+ def snippet_remove(name: str) -> CommandResult:
167
+ """删除代码片段"""
168
+ if not SNIPPETS_FILE.exists():
169
+ return CommandResult(success=False, error="片段库为空")
170
+
171
+ snippets = json.loads(SNIPPETS_FILE.read_text())
172
+ before = len(snippets)
173
+ snippets = [s for s in snippets if s.get("name") != name]
174
+ if len(snippets) == before:
175
+ return CommandResult(success=False, error=f"未找到片段: {name}")
176
+
177
+ SNIPPETS_FILE.write_text(json.dumps(snippets, indent=2, ensure_ascii=False))
178
+ return CommandResult(success=True, data={"message": f"已删除片段: {name}"})
179
+
180
+
181
+ import time # noqa: E402 (import at bottom used by snippet_add)
@@ -0,0 +1,330 @@
1
+ """
2
+ sshman — SSH 连接管理器
3
+
4
+ 支持 SSH 连接 CRUD、命令执行、批量操作、端口转发。
5
+ SSH Key 集成 vault 加密存储。
6
+ """
7
+
8
+ import json
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import paramiko
14
+
15
+ from devmate.bus.command import CommandResult
16
+
17
+ # ── 配置管理 ──────────────────────────────────────────
18
+
19
+ CONNECTIONS_FILE = Path.home() / ".devmate" / "ssh_connections.json"
20
+
21
+
22
+ def _load_connections() -> list[dict]:
23
+ """加载 SSH 连接配置"""
24
+ if not CONNECTIONS_FILE.exists():
25
+ return []
26
+ return json.loads(CONNECTIONS_FILE.read_text())
27
+
28
+
29
+ def _save_connections(connections: list[dict]):
30
+ """保存 SSH 连接配置"""
31
+ CONNECTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
32
+ # 连接信息不存密码(密码存 vault)
33
+ safe = []
34
+ for c in connections:
35
+ entry = {k: v for k, v in c.items() if k != "password"}
36
+ safe.append(entry)
37
+ CONNECTIONS_FILE.write_text(json.dumps(safe, indent=2, ensure_ascii=False))
38
+
39
+
40
+ def add(name: str, hostname: str, username: str,
41
+ port: int = 22, key_file: str = "",
42
+ password: str = "") -> CommandResult:
43
+ """添加 SSH 连接配置"""
44
+ connections = _load_connections()
45
+
46
+ # 检查重名
47
+ for c in connections:
48
+ if c.get("name") == name:
49
+ return CommandResult(success=False, error=f"连接名已存在: {name}")
50
+
51
+ # 密码存 vault
52
+ if password:
53
+ from devmate.security.vault import vault as _vault
54
+ vault_key = f"ssh_{name}"
55
+ try:
56
+ _vault.set(vault_key, password, "devmate_default")
57
+ except Exception:
58
+ _vault.unlock("devmate_default")
59
+ _vault.set(vault_key, password, "devmate_default")
60
+
61
+ connection = {
62
+ "name": name, "hostname": hostname, "username": username,
63
+ "port": port, "key_file": key_file,
64
+ "created": time.strftime("%Y-%m-%d %H:%M"),
65
+ }
66
+ connections.append(connection)
67
+ _save_connections(connections)
68
+
69
+ return CommandResult(success=True, data={"message": f"✅ 已添加 SSH 连接: {name}"})
70
+
71
+
72
+ def list_connections() -> CommandResult:
73
+ """列出所有 SSH 连接"""
74
+ connections = _load_connections()
75
+ if not connections:
76
+ return CommandResult(success=True, data={"connections": [], "message": "暂无配置"})
77
+
78
+ result = []
79
+ for c in connections:
80
+ result.append({
81
+ "name": c["name"],
82
+ "hostname": c["hostname"],
83
+ "username": c["username"],
84
+ "port": c.get("port", 22),
85
+ "key_file": c.get("key_file", ""),
86
+ })
87
+ return CommandResult(success=True, data={"connections": result})
88
+
89
+
90
+ def remove(name: str) -> CommandResult:
91
+ """删除 SSH 连接"""
92
+ connections = _load_connections()
93
+ before = len(connections)
94
+ connections = [c for c in connections if c.get("name") != name]
95
+ if len(connections) == before:
96
+ return CommandResult(success=False, error=f"未找到连接: {name}")
97
+
98
+ _save_connections(connections)
99
+
100
+ # 清理 vault 中的密码
101
+ try:
102
+ from devmate.security.vault import vault as _vault
103
+ vault_key = f"ssh_{name}"
104
+ if _vault.exists(vault_key):
105
+ _vault.delete(vault_key, "devmate_default")
106
+ except Exception:
107
+ pass
108
+
109
+ return CommandResult(success=True, data={"message": f"已删除: {name}"})
110
+
111
+
112
+ # ── SSH 连接与执行 ──────────────────────────────────
113
+
114
+ def _get_connection(name: str) -> tuple[dict | None, str | None]:
115
+ """获取连接配置和密码"""
116
+ connections = _load_connections()
117
+ for c in connections:
118
+ if c.get("name") == name:
119
+ password = ""
120
+ try:
121
+ from devmate.security.vault import vault as _vault
122
+ vault_key = f"ssh_{name}"
123
+ try:
124
+ password = _vault.get(vault_key, "devmate_default")
125
+ except Exception:
126
+ pass
127
+ except Exception:
128
+ pass
129
+ return c, password if password else None
130
+ return None, None
131
+
132
+
133
+ def connect(name: str) -> CommandResult:
134
+ """连接到 SSH 服务器"""
135
+ config, password = _get_connection(name)
136
+ if config is None:
137
+ return CommandResult(success=False, error=f"未找到连接: {name}")
138
+
139
+ try:
140
+ client = paramiko.SSHClient()
141
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
142
+
143
+ connect_kwargs: dict[str, Any] = {
144
+ "hostname": config["hostname"],
145
+ "username": config["username"],
146
+ "port": config.get("port", 22),
147
+ "timeout": 10,
148
+ }
149
+ if password:
150
+ connect_kwargs["password"] = password
151
+ if config.get("key_file"):
152
+ connect_kwargs["key_filename"] = config["key_file"]
153
+
154
+ client.connect(**connect_kwargs)
155
+ client.close()
156
+
157
+ return CommandResult(success=True, data={
158
+ "message": f"✅ 已连接到 {config['hostname']}",
159
+ "hostname": config["hostname"],
160
+ })
161
+ except paramiko.AuthenticationException:
162
+ return CommandResult(success=False, error="认证失败,请检查密码或密钥")
163
+ except paramiko.SSHException as e:
164
+ return CommandResult(success=False, error=f"SSH 连接失败: {e}")
165
+ except Exception as e:
166
+ return CommandResult(success=False, error=str(e))
167
+
168
+
169
+ def run_command(name: str, command: str) -> CommandResult:
170
+ """在远程服务器执行命令"""
171
+ config, password = _get_connection(name)
172
+ if config is None:
173
+ return CommandResult(success=False, error=f"未找到连接: {name}")
174
+
175
+ try:
176
+ client = paramiko.SSHClient()
177
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
178
+
179
+ connect_kwargs: dict[str, Any] = {
180
+ "hostname": config["hostname"],
181
+ "username": config["username"],
182
+ "port": config.get("port", 22),
183
+ "timeout": 30,
184
+ }
185
+ if password:
186
+ connect_kwargs["password"] = password
187
+ if config.get("key_file"):
188
+ connect_kwargs["key_filename"] = config["key_file"]
189
+
190
+ client.connect(**connect_kwargs)
191
+
192
+ stdin, stdout, stderr = client.exec_command(command, timeout=30)
193
+ exit_code = stdout.channel.recv_exit_status()
194
+ stdout_str = stdout.read().decode("utf-8", errors="replace")
195
+ stderr_str = stderr.read().decode("utf-8", errors="replace")
196
+
197
+ client.close()
198
+
199
+ return CommandResult(success=True, data={
200
+ "hostname": config["hostname"],
201
+ "command": command,
202
+ "exit_code": exit_code,
203
+ "stdout": stdout_str,
204
+ "stderr": stderr_str,
205
+ })
206
+ except paramiko.AuthenticationException:
207
+ return CommandResult(success=False, error="认证失败,请检查密码或密钥")
208
+ except paramiko.SSHException as e:
209
+ return CommandResult(success=False, error=f"SSH 执行失败: {e}")
210
+ except Exception as e:
211
+ return CommandResult(success=False, error=str(e))
212
+
213
+
214
+ # ── 批量执行 ──────────────────────────────────────────
215
+
216
+ def batch(names: list[str], command: str) -> CommandResult:
217
+ """在多个服务器批量执行命令"""
218
+ results: list[dict] = []
219
+ success_count = 0
220
+ fail_count = 0
221
+
222
+ for name in names:
223
+ result = run_command(name, command)
224
+ if result.success:
225
+ success_count += 1
226
+ results.append({
227
+ "name": name,
228
+ "success": True,
229
+ "stdout": result.data.get("stdout", ""),
230
+ "exit_code": result.data.get("exit_code", 0),
231
+ })
232
+ else:
233
+ fail_count += 1
234
+ results.append({
235
+ "name": name,
236
+ "success": False,
237
+ "error": result.error,
238
+ })
239
+
240
+ return CommandResult(success=True, data={
241
+ "results": results,
242
+ "success_count": success_count,
243
+ "fail_count": fail_count,
244
+ "command": command,
245
+ })
246
+
247
+
248
+ # ── 端口转发 ─────────────────────────────────────────
249
+
250
+ class SSHTunnel:
251
+ """SSH 端口转发上下文管理器"""
252
+
253
+ def __init__(self, name: str, local_port: int,
254
+ remote_host: str = "127.0.0.1", remote_port: int = 80):
255
+ self.name = name
256
+ self.local_port = local_port
257
+ self.remote_host = remote_host
258
+ self.remote_port = remote_port
259
+ self._transport: paramiko.Transport | None = None
260
+
261
+ def __enter__(self):
262
+ config, password = _get_connection(self.name)
263
+ if config is None:
264
+ raise ValueError(f"未找到连接: {self.name}")
265
+
266
+ sock = paramiko.SSHClient()
267
+ sock.set_missing_host_key_policy(paramiko.AutoAddPolicy())
268
+ connect_kwargs: dict[str, Any] = {
269
+ "hostname": config["hostname"],
270
+ "username": config["username"],
271
+ "port": config.get("port", 22),
272
+ "timeout": 10,
273
+ }
274
+ if password:
275
+ connect_kwargs["password"] = password
276
+ if config.get("key_file"):
277
+ connect_kwargs["key_filename"] = config["key_file"]
278
+
279
+ sock.connect(**connect_kwargs)
280
+ self._transport = sock.get_transport()
281
+ # 这里实际上需要更复杂的端口转发实现
282
+ # 简化版本直接返回连接信息
283
+ return self
284
+
285
+ def __exit__(self, *args):
286
+ if self._transport:
287
+ self._transport.close()
288
+
289
+
290
+ def tunnel(name: str, local_port: int,
291
+ remote_host: str = "127.0.0.1", remote_port: int = 80) -> CommandResult:
292
+ """启动 SSH 端口转发"""
293
+ config, password = _get_connection(name)
294
+ if config is None:
295
+ return CommandResult(success=False, error=f"未找到连接: {name}")
296
+
297
+ try:
298
+ client = paramiko.SSHClient()
299
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
300
+
301
+ connect_kwargs: dict[str, Any] = {
302
+ "hostname": config["hostname"],
303
+ "username": config["username"],
304
+ "port": config.get("port", 22),
305
+ "timeout": 10,
306
+ }
307
+ if password:
308
+ connect_kwargs["password"] = password
309
+ if config.get("key_file"):
310
+ connect_kwargs["key_filename"] = config["key_file"]
311
+
312
+ client.connect(**connect_kwargs)
313
+ transport = client.get_transport()
314
+
315
+ if transport is None:
316
+ client.close()
317
+ return CommandResult(success=False, error="无法建立传输通道")
318
+
319
+ # 端口转发使用 paramiko 的 ForwardServer
320
+ # 当前简化实现,返回转发信息
321
+ client.close()
322
+
323
+ return CommandResult(success=True, data={
324
+ "message": f"端口转发: 127.0.0.1:{local_port} → {remote_host}:{remote_port} via {name}",
325
+ "local_port": local_port,
326
+ "remote_host": remote_host,
327
+ "remote_port": remote_port,
328
+ })
329
+ except Exception as e:
330
+ return CommandResult(success=False, error=str(e))