qodalis-cli-server 1.0.0b1__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.
- qodalis_cli/__init__.py +112 -0
- qodalis_cli/abstractions/__init__.py +25 -0
- qodalis_cli/abstractions/cli_command_author.py +7 -0
- qodalis_cli/abstractions/cli_command_parameter_descriptor.py +6 -0
- qodalis_cli/abstractions/cli_command_processor.py +6 -0
- qodalis_cli/abstractions/cli_process_command.py +3 -0
- qodalis_cli/controllers/__init__.py +11 -0
- qodalis_cli/controllers/cli_controller.py +111 -0
- qodalis_cli/controllers/cli_controller_v2.py +91 -0
- qodalis_cli/controllers/cli_version_controller.py +45 -0
- qodalis_cli/controllers/filesystem_controller.py +329 -0
- qodalis_cli/create_cli_server.py +157 -0
- qodalis_cli/extensions/__init__.py +3 -0
- qodalis_cli/extensions/cli_builder.py +51 -0
- qodalis_cli/filesystem/__init__.py +4 -0
- qodalis_cli/filesystem/filesystem_options.py +16 -0
- qodalis_cli/filesystem/filesystem_path_validator.py +42 -0
- qodalis_cli/models/__init__.py +13 -0
- qodalis_cli/models/cli_server_command_descriptor.py +30 -0
- qodalis_cli/models/cli_server_output.py +47 -0
- qodalis_cli/models/cli_server_response.py +12 -0
- qodalis_cli/processors/__init__.py +17 -0
- qodalis_cli/processors/cli_base64_command_processor.py +61 -0
- qodalis_cli/processors/cli_echo_command_processor.py +17 -0
- qodalis_cli/processors/cli_hash_command_processor.py +49 -0
- qodalis_cli/processors/cli_http_command_processor.py +143 -0
- qodalis_cli/processors/cli_status_command_processor.py +24 -0
- qodalis_cli/processors/cli_system_command_processor.py +34 -0
- qodalis_cli/processors/cli_uuid_command_processor.py +39 -0
- qodalis_cli/server.py +44 -0
- qodalis_cli/services/__init__.py +19 -0
- qodalis_cli/services/cli_command_executor_service.py +62 -0
- qodalis_cli/services/cli_command_registry.py +68 -0
- qodalis_cli/services/cli_event_socket_manager.py +46 -0
- qodalis_cli/services/cli_log_socket_manager.py +153 -0
- qodalis_cli/services/cli_response_builder.py +73 -0
- qodalis_cli/services/cli_shell_session_manager.py +201 -0
- qodalis_cli_server-1.0.0b1.dist-info/METADATA +689 -0
- qodalis_cli_server-1.0.0b1.dist-info/RECORD +42 -0
- qodalis_cli_server-1.0.0b1.dist-info/WHEEL +4 -0
- qodalis_cli_server-1.0.0b1.dist-info/entry_points.txt +2 -0
- qodalis_cli_server-1.0.0b1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import stat
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Union
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
|
11
|
+
from fastapi.responses import FileResponse, StreamingResponse
|
|
12
|
+
|
|
13
|
+
from qodalis_cli_filesystem import (
|
|
14
|
+
FileStorageExistsError,
|
|
15
|
+
FileStorageIsADirectoryError,
|
|
16
|
+
FileStorageNotADirectoryError,
|
|
17
|
+
FileStorageNotFoundError,
|
|
18
|
+
FileStoragePermissionError,
|
|
19
|
+
IFileStorageProvider,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from ..filesystem.filesystem_path_validator import FileSystemPathValidator
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _map_provider_error(exc: Exception) -> HTTPException:
|
|
26
|
+
"""Map a file-storage error to the appropriate HTTP exception."""
|
|
27
|
+
if isinstance(exc, FileStorageNotFoundError):
|
|
28
|
+
return HTTPException(status_code=404, detail=str(exc))
|
|
29
|
+
if isinstance(exc, FileStoragePermissionError):
|
|
30
|
+
return HTTPException(status_code=403, detail=str(exc))
|
|
31
|
+
if isinstance(exc, (FileStorageNotADirectoryError, FileStorageIsADirectoryError)):
|
|
32
|
+
return HTTPException(status_code=400, detail=str(exc))
|
|
33
|
+
if isinstance(exc, FileStorageExistsError):
|
|
34
|
+
return HTTPException(status_code=409, detail=str(exc))
|
|
35
|
+
return HTTPException(status_code=500, detail=str(exc))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Provider-based router (new)
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_filesystem_router(
|
|
44
|
+
provider_or_validator: Union[IFileStorageProvider, FileSystemPathValidator],
|
|
45
|
+
) -> APIRouter:
|
|
46
|
+
"""Create a FastAPI router exposing filesystem operations.
|
|
47
|
+
|
|
48
|
+
Accepts either a :class:`IFileStorageProvider` (new approach) or a
|
|
49
|
+
:class:`FileSystemPathValidator` (legacy approach) for backward
|
|
50
|
+
compatibility.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(provider_or_validator, FileSystemPathValidator):
|
|
53
|
+
return _create_legacy_router(provider_or_validator)
|
|
54
|
+
|
|
55
|
+
return _create_provider_router(provider_or_validator)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _create_provider_router(provider: IFileStorageProvider) -> APIRouter:
|
|
59
|
+
"""Create a router that delegates to *provider*."""
|
|
60
|
+
|
|
61
|
+
router = APIRouter()
|
|
62
|
+
|
|
63
|
+
@router.get("/ls")
|
|
64
|
+
async def list_directory(path: str) -> dict[str, Any]:
|
|
65
|
+
try:
|
|
66
|
+
entries = await provider.list(path)
|
|
67
|
+
return {"entries": [asdict(e) for e in entries]}
|
|
68
|
+
except (
|
|
69
|
+
FileStorageNotFoundError,
|
|
70
|
+
FileStorageNotADirectoryError,
|
|
71
|
+
FileStoragePermissionError,
|
|
72
|
+
) as exc:
|
|
73
|
+
raise _map_provider_error(exc) from exc
|
|
74
|
+
|
|
75
|
+
@router.get("/cat")
|
|
76
|
+
async def read_file(path: str) -> dict[str, str]:
|
|
77
|
+
try:
|
|
78
|
+
content = await provider.read_file(path)
|
|
79
|
+
return {"content": content}
|
|
80
|
+
except (
|
|
81
|
+
FileStorageNotFoundError,
|
|
82
|
+
FileStorageIsADirectoryError,
|
|
83
|
+
FileStoragePermissionError,
|
|
84
|
+
) as exc:
|
|
85
|
+
raise _map_provider_error(exc) from exc
|
|
86
|
+
|
|
87
|
+
@router.get("/stat")
|
|
88
|
+
async def stat_path(path: str) -> dict[str, Any]:
|
|
89
|
+
try:
|
|
90
|
+
info = await provider.stat(path)
|
|
91
|
+
return asdict(info)
|
|
92
|
+
except (
|
|
93
|
+
FileStorageNotFoundError,
|
|
94
|
+
FileStoragePermissionError,
|
|
95
|
+
) as exc:
|
|
96
|
+
raise _map_provider_error(exc) from exc
|
|
97
|
+
|
|
98
|
+
@router.get("/download")
|
|
99
|
+
async def download_file(path: str) -> StreamingResponse:
|
|
100
|
+
try:
|
|
101
|
+
stream = await provider.get_download_stream(path)
|
|
102
|
+
filename = path.rstrip("/").rsplit("/", 1)[-1] or "download"
|
|
103
|
+
return StreamingResponse(
|
|
104
|
+
stream,
|
|
105
|
+
media_type="application/octet-stream",
|
|
106
|
+
headers={
|
|
107
|
+
"Content-Disposition": f'attachment; filename="{filename}"'
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
except (
|
|
111
|
+
FileStorageNotFoundError,
|
|
112
|
+
FileStorageIsADirectoryError,
|
|
113
|
+
FileStoragePermissionError,
|
|
114
|
+
) as exc:
|
|
115
|
+
raise _map_provider_error(exc) from exc
|
|
116
|
+
|
|
117
|
+
@router.post("/upload")
|
|
118
|
+
async def upload_file(
|
|
119
|
+
file: UploadFile = File(...),
|
|
120
|
+
path: str = Form(...),
|
|
121
|
+
) -> dict[str, str]:
|
|
122
|
+
try:
|
|
123
|
+
contents = await file.read()
|
|
124
|
+
await provider.upload_file(path, contents)
|
|
125
|
+
return {"path": path, "status": "uploaded"}
|
|
126
|
+
except (
|
|
127
|
+
FileStorageNotFoundError,
|
|
128
|
+
FileStorageIsADirectoryError,
|
|
129
|
+
FileStoragePermissionError,
|
|
130
|
+
) as exc:
|
|
131
|
+
raise _map_provider_error(exc) from exc
|
|
132
|
+
|
|
133
|
+
@router.post("/mkdir")
|
|
134
|
+
async def make_directory(body: dict[str, Any]) -> dict[str, str]:
|
|
135
|
+
raw_path = body.get("path")
|
|
136
|
+
if not raw_path:
|
|
137
|
+
raise HTTPException(status_code=400, detail="'path' is required")
|
|
138
|
+
|
|
139
|
+
recursive = body.get("recursive", True)
|
|
140
|
+
try:
|
|
141
|
+
await provider.mkdir(raw_path, recursive=recursive)
|
|
142
|
+
return {"path": raw_path, "status": "created"}
|
|
143
|
+
except (
|
|
144
|
+
FileStorageNotFoundError,
|
|
145
|
+
FileStorageExistsError,
|
|
146
|
+
FileStoragePermissionError,
|
|
147
|
+
) as exc:
|
|
148
|
+
raise _map_provider_error(exc) from exc
|
|
149
|
+
|
|
150
|
+
@router.delete("/rm")
|
|
151
|
+
async def remove_path(path: str, recursive: bool = True) -> dict[str, str]:
|
|
152
|
+
try:
|
|
153
|
+
await provider.remove(path, recursive=recursive)
|
|
154
|
+
return {"path": path, "status": "deleted"}
|
|
155
|
+
except (
|
|
156
|
+
FileStorageNotFoundError,
|
|
157
|
+
FileStoragePermissionError,
|
|
158
|
+
) as exc:
|
|
159
|
+
raise _map_provider_error(exc) from exc
|
|
160
|
+
|
|
161
|
+
return router
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Legacy validator-based router (backward compatible)
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _safe_validate(validator: FileSystemPathValidator, path: str) -> str:
|
|
170
|
+
"""Validate *path* via the validator, raising a 403 on failure."""
|
|
171
|
+
try:
|
|
172
|
+
return validator.validate(path)
|
|
173
|
+
except PermissionError as exc:
|
|
174
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _stat_entry(resolved: str) -> dict[str, Any]:
|
|
178
|
+
"""Return a stat dictionary for the given resolved path."""
|
|
179
|
+
st = os.stat(resolved)
|
|
180
|
+
entry_type = "directory" if stat.S_ISDIR(st.st_mode) else "file"
|
|
181
|
+
return {
|
|
182
|
+
"name": os.path.basename(resolved),
|
|
183
|
+
"type": entry_type,
|
|
184
|
+
"size": st.st_size,
|
|
185
|
+
"modified": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
|
|
186
|
+
"created": datetime.fromtimestamp(st.st_ctime, tz=timezone.utc).isoformat(),
|
|
187
|
+
"permissions": stat.filemode(st.st_mode),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _create_legacy_router(validator: FileSystemPathValidator) -> APIRouter:
|
|
192
|
+
"""Create a router using the legacy :class:`FileSystemPathValidator`."""
|
|
193
|
+
|
|
194
|
+
router = APIRouter()
|
|
195
|
+
|
|
196
|
+
@router.get("/ls")
|
|
197
|
+
async def list_directory(path: str) -> dict[str, Any]:
|
|
198
|
+
resolved = _safe_validate(validator, path)
|
|
199
|
+
|
|
200
|
+
if not os.path.exists(resolved):
|
|
201
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
202
|
+
if not os.path.isdir(resolved):
|
|
203
|
+
raise HTTPException(status_code=400, detail="Path is not a directory")
|
|
204
|
+
|
|
205
|
+
entries: list[dict[str, Any]] = []
|
|
206
|
+
try:
|
|
207
|
+
for name in sorted(os.listdir(resolved)):
|
|
208
|
+
full = os.path.join(resolved, name)
|
|
209
|
+
try:
|
|
210
|
+
st = os.stat(full)
|
|
211
|
+
entry_type = "directory" if stat.S_ISDIR(st.st_mode) else "file"
|
|
212
|
+
entries.append(
|
|
213
|
+
{
|
|
214
|
+
"name": name,
|
|
215
|
+
"type": entry_type,
|
|
216
|
+
"size": st.st_size,
|
|
217
|
+
"modified": datetime.fromtimestamp(
|
|
218
|
+
st.st_mtime, tz=timezone.utc
|
|
219
|
+
).isoformat(),
|
|
220
|
+
"permissions": stat.filemode(st.st_mode),
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
except OSError:
|
|
224
|
+
continue
|
|
225
|
+
except OSError as exc:
|
|
226
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
227
|
+
|
|
228
|
+
return {"entries": entries}
|
|
229
|
+
|
|
230
|
+
@router.get("/cat")
|
|
231
|
+
async def read_file(path: str) -> dict[str, str]:
|
|
232
|
+
resolved = _safe_validate(validator, path)
|
|
233
|
+
|
|
234
|
+
if not os.path.exists(resolved):
|
|
235
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
236
|
+
if not os.path.isfile(resolved):
|
|
237
|
+
raise HTTPException(status_code=400, detail="Path is not a file")
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
with open(resolved, "r", encoding="utf-8", errors="replace") as fh:
|
|
241
|
+
content = fh.read()
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
244
|
+
|
|
245
|
+
return {"content": content}
|
|
246
|
+
|
|
247
|
+
@router.get("/stat")
|
|
248
|
+
async def stat_path(path: str) -> dict[str, Any]:
|
|
249
|
+
resolved = _safe_validate(validator, path)
|
|
250
|
+
|
|
251
|
+
if not os.path.exists(resolved):
|
|
252
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
return _stat_entry(resolved)
|
|
256
|
+
except OSError as exc:
|
|
257
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
258
|
+
|
|
259
|
+
@router.get("/download")
|
|
260
|
+
async def download_file(path: str) -> FileResponse:
|
|
261
|
+
resolved = _safe_validate(validator, path)
|
|
262
|
+
|
|
263
|
+
if not os.path.exists(resolved):
|
|
264
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
265
|
+
if not os.path.isfile(resolved):
|
|
266
|
+
raise HTTPException(status_code=400, detail="Path is not a file")
|
|
267
|
+
|
|
268
|
+
filename = os.path.basename(resolved)
|
|
269
|
+
return FileResponse(
|
|
270
|
+
resolved,
|
|
271
|
+
filename=filename,
|
|
272
|
+
media_type="application/octet-stream",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@router.post("/upload")
|
|
276
|
+
async def upload_file(
|
|
277
|
+
file: UploadFile = File(...),
|
|
278
|
+
path: str = Form(...),
|
|
279
|
+
) -> dict[str, str]:
|
|
280
|
+
resolved = _safe_validate(validator, path)
|
|
281
|
+
|
|
282
|
+
parent = os.path.dirname(resolved)
|
|
283
|
+
if not os.path.isdir(parent):
|
|
284
|
+
raise HTTPException(
|
|
285
|
+
status_code=400, detail="Parent directory does not exist"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
contents = await file.read()
|
|
290
|
+
with open(resolved, "wb") as fh:
|
|
291
|
+
fh.write(contents)
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
294
|
+
|
|
295
|
+
return {"path": resolved, "status": "uploaded"}
|
|
296
|
+
|
|
297
|
+
@router.post("/mkdir")
|
|
298
|
+
async def make_directory(body: dict[str, str]) -> dict[str, str]:
|
|
299
|
+
raw_path = body.get("path")
|
|
300
|
+
if not raw_path:
|
|
301
|
+
raise HTTPException(status_code=400, detail="'path' is required")
|
|
302
|
+
|
|
303
|
+
resolved = _safe_validate(validator, raw_path)
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
os.makedirs(resolved, exist_ok=True)
|
|
307
|
+
except OSError as exc:
|
|
308
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
309
|
+
|
|
310
|
+
return {"path": resolved, "status": "created"}
|
|
311
|
+
|
|
312
|
+
@router.delete("/rm")
|
|
313
|
+
async def remove_path(path: str) -> dict[str, str]:
|
|
314
|
+
resolved = _safe_validate(validator, path)
|
|
315
|
+
|
|
316
|
+
if not os.path.exists(resolved):
|
|
317
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
if os.path.isdir(resolved):
|
|
321
|
+
shutil.rmtree(resolved)
|
|
322
|
+
else:
|
|
323
|
+
os.remove(resolved)
|
|
324
|
+
except OSError as exc:
|
|
325
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
326
|
+
|
|
327
|
+
return {"path": resolved, "status": "deleted"}
|
|
328
|
+
|
|
329
|
+
return router
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, WebSocket
|
|
10
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
11
|
+
|
|
12
|
+
from qodalis_cli_filesystem.providers.os_provider import OsFileStorageProvider, OsProviderOptions
|
|
13
|
+
|
|
14
|
+
from .controllers import create_cli_router, create_cli_router_v2, create_cli_version_router
|
|
15
|
+
from .controllers.filesystem_controller import create_filesystem_router
|
|
16
|
+
from .extensions import CliBuilder
|
|
17
|
+
from .filesystem import FileSystemPathValidator
|
|
18
|
+
from .services import (
|
|
19
|
+
CliCommandExecutorService,
|
|
20
|
+
CliCommandRegistry,
|
|
21
|
+
CliEventSocketManager,
|
|
22
|
+
CliLogSocketManager,
|
|
23
|
+
CliShellSessionManager,
|
|
24
|
+
WebSocketLogHandler,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CliServerOptions:
|
|
30
|
+
base_path: str = "/api/cli"
|
|
31
|
+
cors: bool = True
|
|
32
|
+
cors_origins: list[str] = field(default_factory=lambda: ["*"])
|
|
33
|
+
configure: Callable[[CliBuilder], None] | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class CliServerResult:
|
|
38
|
+
app: FastAPI
|
|
39
|
+
registry: CliCommandRegistry
|
|
40
|
+
builder: CliBuilder
|
|
41
|
+
event_socket_manager: CliEventSocketManager
|
|
42
|
+
log_socket_manager: CliLogSocketManager
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_cli_server(options: CliServerOptions | None = None) -> CliServerResult:
|
|
46
|
+
opts = options or CliServerOptions()
|
|
47
|
+
|
|
48
|
+
event_socket_manager = CliEventSocketManager()
|
|
49
|
+
log_socket_manager = CliLogSocketManager()
|
|
50
|
+
shell_session_manager = CliShellSessionManager()
|
|
51
|
+
|
|
52
|
+
# Attach a log handler that forwards to WebSocket clients
|
|
53
|
+
log_handler = WebSocketLogHandler(log_socket_manager)
|
|
54
|
+
logging.getLogger().addHandler(log_handler)
|
|
55
|
+
|
|
56
|
+
@asynccontextmanager
|
|
57
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
58
|
+
yield
|
|
59
|
+
await event_socket_manager.broadcast_disconnect()
|
|
60
|
+
await log_socket_manager.broadcast_disconnect()
|
|
61
|
+
logging.getLogger().removeHandler(log_handler)
|
|
62
|
+
|
|
63
|
+
app = FastAPI(title="Qodalis CLI Server", lifespan=lifespan)
|
|
64
|
+
|
|
65
|
+
if opts.cors:
|
|
66
|
+
app.add_middleware(
|
|
67
|
+
CORSMiddleware,
|
|
68
|
+
allow_origins=opts.cors_origins,
|
|
69
|
+
allow_credentials=True,
|
|
70
|
+
allow_methods=["*"],
|
|
71
|
+
allow_headers=["*"],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
registry = CliCommandRegistry()
|
|
75
|
+
builder = CliBuilder(registry)
|
|
76
|
+
|
|
77
|
+
if opts.configure:
|
|
78
|
+
opts.configure(builder)
|
|
79
|
+
|
|
80
|
+
executor = CliCommandExecutorService(registry)
|
|
81
|
+
router = create_cli_router(registry, executor)
|
|
82
|
+
router_v2 = create_cli_router_v2(registry, executor)
|
|
83
|
+
version_router = create_cli_version_router()
|
|
84
|
+
|
|
85
|
+
# API v1 routes
|
|
86
|
+
app.include_router(router, prefix="/api/v1/cli")
|
|
87
|
+
|
|
88
|
+
# API v2 routes
|
|
89
|
+
app.include_router(router_v2, prefix="/api/v2/cli")
|
|
90
|
+
|
|
91
|
+
# Version discovery routes
|
|
92
|
+
app.include_router(version_router, prefix="/api/cli")
|
|
93
|
+
|
|
94
|
+
# Custom basePath fallback (when user overrides the default)
|
|
95
|
+
if opts.base_path != "/api/v1/cli":
|
|
96
|
+
app.include_router(router, prefix=opts.base_path)
|
|
97
|
+
|
|
98
|
+
# Filesystem API (opt-in via builder.set_file_storage_provider() or
|
|
99
|
+
# legacy builder.add_filesystem())
|
|
100
|
+
if builder.file_storage_provider is not None:
|
|
101
|
+
fs_router = create_filesystem_router(builder.file_storage_provider)
|
|
102
|
+
app.include_router(fs_router, prefix="/api/cli/fs")
|
|
103
|
+
elif builder.filesystem_options is not None:
|
|
104
|
+
# Legacy path: create an OsFileStorageProvider from the options
|
|
105
|
+
os_provider = OsFileStorageProvider(
|
|
106
|
+
OsProviderOptions(
|
|
107
|
+
allowed_paths=builder.filesystem_options.allowed_paths
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
fs_router = create_filesystem_router(os_provider)
|
|
111
|
+
app.include_router(fs_router, prefix="/api/cli/fs")
|
|
112
|
+
|
|
113
|
+
# WebSocket event stream
|
|
114
|
+
@app.websocket("/ws/v1/cli/events")
|
|
115
|
+
async def websocket_events_v1(websocket: WebSocket) -> None:
|
|
116
|
+
await event_socket_manager.handle_connection(websocket)
|
|
117
|
+
|
|
118
|
+
@app.websocket("/ws/cli/events")
|
|
119
|
+
async def websocket_events(websocket: WebSocket) -> None:
|
|
120
|
+
await event_socket_manager.handle_connection(websocket)
|
|
121
|
+
|
|
122
|
+
# Log WebSocket endpoints
|
|
123
|
+
async def _handle_logs(websocket: WebSocket) -> None:
|
|
124
|
+
level_filter = websocket.query_params.get("level") or None
|
|
125
|
+
await log_socket_manager.handle_connection(websocket, level_filter)
|
|
126
|
+
|
|
127
|
+
@app.websocket("/ws/v1/cli/logs")
|
|
128
|
+
async def websocket_logs_v1(websocket: WebSocket) -> None:
|
|
129
|
+
await _handle_logs(websocket)
|
|
130
|
+
|
|
131
|
+
@app.websocket("/ws/cli/logs")
|
|
132
|
+
async def websocket_logs(websocket: WebSocket) -> None:
|
|
133
|
+
await _handle_logs(websocket)
|
|
134
|
+
|
|
135
|
+
# Shell WebSocket endpoints
|
|
136
|
+
async def _handle_shell(websocket: WebSocket) -> None:
|
|
137
|
+
await websocket.accept()
|
|
138
|
+
cols = int(websocket.query_params.get("cols", "80")) or 80
|
|
139
|
+
rows = int(websocket.query_params.get("rows", "24")) or 24
|
|
140
|
+
cmd = websocket.query_params.get("cmd") or None
|
|
141
|
+
await shell_session_manager.handle_session(websocket, cols, rows, cmd)
|
|
142
|
+
|
|
143
|
+
@app.websocket("/ws/v1/cli/shell")
|
|
144
|
+
async def websocket_shell_v1(websocket: WebSocket) -> None:
|
|
145
|
+
await _handle_shell(websocket)
|
|
146
|
+
|
|
147
|
+
@app.websocket("/ws/cli/shell")
|
|
148
|
+
async def websocket_shell(websocket: WebSocket) -> None:
|
|
149
|
+
await _handle_shell(websocket)
|
|
150
|
+
|
|
151
|
+
return CliServerResult(
|
|
152
|
+
app=app,
|
|
153
|
+
registry=registry,
|
|
154
|
+
builder=builder,
|
|
155
|
+
event_socket_manager=event_socket_manager,
|
|
156
|
+
log_socket_manager=log_socket_manager,
|
|
157
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..abstractions import ICliCommandProcessor, ICliModule
|
|
6
|
+
from ..filesystem import FileSystemOptions
|
|
7
|
+
from ..services.cli_command_registry import CliCommandRegistry
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from qodalis_cli_filesystem import IFileStorageProvider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CliBuilder:
|
|
14
|
+
def __init__(self, registry: CliCommandRegistry) -> None:
|
|
15
|
+
self._registry = registry
|
|
16
|
+
self._filesystem_options: FileSystemOptions | None = None
|
|
17
|
+
self._file_storage_provider: IFileStorageProvider | None = None
|
|
18
|
+
|
|
19
|
+
def add_processor(self, processor: ICliCommandProcessor) -> CliBuilder:
|
|
20
|
+
self._registry.register(processor)
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
def add_module(self, module: ICliModule) -> CliBuilder:
|
|
24
|
+
for processor in module.processors:
|
|
25
|
+
self._registry.register(processor)
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def add_filesystem(self, options: FileSystemOptions) -> CliBuilder:
|
|
29
|
+
"""Enable the filesystem API with the given options (legacy)."""
|
|
30
|
+
self._filesystem_options = options
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def set_file_storage_provider(
|
|
34
|
+
self, provider: IFileStorageProvider
|
|
35
|
+
) -> CliBuilder:
|
|
36
|
+
"""Set a custom file storage provider for the filesystem API."""
|
|
37
|
+
self._file_storage_provider = provider
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def file_storage_provider(self) -> IFileStorageProvider | None:
|
|
42
|
+
"""Return the configured file storage provider, if any."""
|
|
43
|
+
return self._file_storage_provider
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def filesystem_options(self) -> FileSystemOptions | None:
|
|
47
|
+
return self._filesystem_options
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def registry(self) -> CliCommandRegistry:
|
|
51
|
+
return self._registry
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class FileSystemOptions:
|
|
8
|
+
"""Configuration for the filesystem API.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
allowed_paths: List of absolute directory paths that clients are
|
|
12
|
+
permitted to access. Every requested path is resolved and
|
|
13
|
+
checked against this whitelist before any I/O is performed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
allowed_paths: list[str] = field(default_factory=list)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FileSystemPathValidator:
|
|
7
|
+
"""Validates that requested paths fall within an allowed whitelist.
|
|
8
|
+
|
|
9
|
+
Every path is resolved to its real, absolute form (following symlinks)
|
|
10
|
+
before the check, which prevents path-traversal attacks.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, allowed_paths: list[str]) -> None:
|
|
14
|
+
# Pre-resolve allowed roots so comparisons are consistent.
|
|
15
|
+
self._allowed_paths: list[str] = [
|
|
16
|
+
os.path.realpath(p) for p in allowed_paths
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def allowed_paths(self) -> list[str]:
|
|
21
|
+
return list(self._allowed_paths)
|
|
22
|
+
|
|
23
|
+
def validate(self, path: str) -> str:
|
|
24
|
+
"""Resolve *path* and verify it lives under an allowed root.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The resolved absolute path on success.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
PermissionError: If the resolved path is not within any
|
|
31
|
+
allowed directory.
|
|
32
|
+
"""
|
|
33
|
+
resolved = os.path.realpath(path)
|
|
34
|
+
|
|
35
|
+
for allowed in self._allowed_paths:
|
|
36
|
+
# Use os.sep to ensure "/tmp/evil" doesn't match "/tmp/ev"
|
|
37
|
+
if resolved == allowed or resolved.startswith(allowed + os.sep):
|
|
38
|
+
return resolved
|
|
39
|
+
|
|
40
|
+
raise PermissionError(
|
|
41
|
+
f"Access denied: '{path}' is outside the allowed paths"
|
|
42
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .cli_server_output import CliServerOutput
|
|
2
|
+
from .cli_server_response import CliServerResponse
|
|
3
|
+
from .cli_server_command_descriptor import (
|
|
4
|
+
CliServerCommandDescriptor,
|
|
5
|
+
CliServerCommandParameterDescriptorDto,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CliServerOutput",
|
|
10
|
+
"CliServerResponse",
|
|
11
|
+
"CliServerCommandDescriptor",
|
|
12
|
+
"CliServerCommandParameterDescriptorDto",
|
|
13
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CliServerCommandParameterDescriptorDto(BaseModel):
|
|
9
|
+
name: str
|
|
10
|
+
description: str
|
|
11
|
+
required: bool = False
|
|
12
|
+
type: str = "string"
|
|
13
|
+
aliases: list[str] | None = None
|
|
14
|
+
default_value: Any = Field(alias="defaultValue", default=None)
|
|
15
|
+
|
|
16
|
+
model_config = {"populate_by_name": True}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CliServerCommandDescriptor(BaseModel):
|
|
20
|
+
command: str
|
|
21
|
+
description: str
|
|
22
|
+
version: str = "1.0.0"
|
|
23
|
+
api_version: int | None = Field(alias="apiVersion", default=None)
|
|
24
|
+
parameters: list[CliServerCommandParameterDescriptorDto] | None = None
|
|
25
|
+
processors: list["CliServerCommandDescriptor"] | None = None
|
|
26
|
+
allow_unlisted_commands: bool | None = Field(alias="allowUnlistedCommands", default=None)
|
|
27
|
+
value_required: bool | None = Field(alias="valueRequired", default=None)
|
|
28
|
+
author: dict[str, str] | None = None
|
|
29
|
+
|
|
30
|
+
model_config = {"populate_by_name": True}
|