cyclo-manager 1.1.0.dev1__tar.gz → 1.1.0.dev2__tar.gz
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.
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/PKG-INFO +3 -4
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/README.md +2 -3
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/models.py +31 -0
- cyclo_manager-1.1.0.dev2/cyclo_host_agent/routers/files.py +540 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/PKG-INFO +3 -4
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/pyproject.toml +1 -1
- cyclo_manager-1.1.0.dev1/cyclo_host_agent/routers/files.py +0 -216
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/__init__.py +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/main.py +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/routers/__init__.py +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/routers/repos.py +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/routers/system_stats.py +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/routers/update.py +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/SOURCES.txt +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/dependency_links.txt +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/entry_points.txt +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/requires.txt +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/top_level.txt +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager_cli/__init__.py +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager_cli/cli.py +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager_cli/config/config.yml +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager_cli/docker/docker-compose.yml +0 -0
- {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cyclo-manager
|
|
3
|
-
Version: 1.1.0.
|
|
3
|
+
Version: 1.1.0.dev2
|
|
4
4
|
Summary: cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack.
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -137,7 +137,7 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
|
|
|
137
137
|
|
|
138
138
|
`cyclo_manager up` installs the `refresh-host-agent` CLI subcommand into sudoers. It is registered as `sys.executable -m cyclo_manager_cli.cli refresh-host-agent` with `SETENV`, so UI-triggered updates can pass the current `PYTHONPATH` to sudo and do not depend on `PATH`. The command is idempotent and refreshes the socket directory, sudoers file, systemd unit, and service state after package upgrades.
|
|
139
139
|
|
|
140
|
-
Repository scanning
|
|
140
|
+
Repository scanning and the Files UI use `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise they use the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically. Only repositories whose `origin` remote belongs to `ROBOTIS-GIT` are returned by the repo list/update endpoints.
|
|
141
141
|
|
|
142
142
|
---
|
|
143
143
|
|
|
@@ -217,8 +217,7 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
|
|
|
217
217
|
| Variable | Set by | Purpose |
|
|
218
218
|
|----------|--------|---------|
|
|
219
219
|
| **`CYCLO_MANAGER_CONFIG_FILE`** | CLI (`up`, `down`, `update`) | Absolute path to bundled `config.yml` on the host; mounted into the API container |
|
|
220
|
-
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories |
|
|
221
|
-
| **`CYCLO_HOST_AGENT_FILE_ROOT`** | User (optional) | Root directory for the Files UI/API. Defaults to the host agent service user's home (`$HOME`, e.g. `/root` when running as root) |
|
|
220
|
+
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories and used as the Files UI/API root |
|
|
222
221
|
| **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
|
|
223
222
|
| **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
|
|
224
223
|
| **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
|
|
@@ -123,7 +123,7 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
|
|
|
123
123
|
|
|
124
124
|
`cyclo_manager up` installs the `refresh-host-agent` CLI subcommand into sudoers. It is registered as `sys.executable -m cyclo_manager_cli.cli refresh-host-agent` with `SETENV`, so UI-triggered updates can pass the current `PYTHONPATH` to sudo and do not depend on `PATH`. The command is idempotent and refreshes the socket directory, sudoers file, systemd unit, and service state after package upgrades.
|
|
125
125
|
|
|
126
|
-
Repository scanning
|
|
126
|
+
Repository scanning and the Files UI use `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise they use the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically. Only repositories whose `origin` remote belongs to `ROBOTIS-GIT` are returned by the repo list/update endpoints.
|
|
127
127
|
|
|
128
128
|
---
|
|
129
129
|
|
|
@@ -203,8 +203,7 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
|
|
|
203
203
|
| Variable | Set by | Purpose |
|
|
204
204
|
|----------|--------|---------|
|
|
205
205
|
| **`CYCLO_MANAGER_CONFIG_FILE`** | CLI (`up`, `down`, `update`) | Absolute path to bundled `config.yml` on the host; mounted into the API container |
|
|
206
|
-
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories |
|
|
207
|
-
| **`CYCLO_HOST_AGENT_FILE_ROOT`** | User (optional) | Root directory for the Files UI/API. Defaults to the host agent service user's home (`$HOME`, e.g. `/root` when running as root) |
|
|
206
|
+
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories and used as the Files UI/API root |
|
|
208
207
|
| **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
|
|
209
208
|
| **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
|
|
210
209
|
| **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
|
|
@@ -160,6 +160,7 @@ class FileTreeEntry(BaseModel):
|
|
|
160
160
|
readonly: bool = False
|
|
161
161
|
hidden: bool = False
|
|
162
162
|
symlink: bool = False
|
|
163
|
+
git_status: str | None = None
|
|
163
164
|
|
|
164
165
|
|
|
165
166
|
class FileTreeResponse(BaseModel):
|
|
@@ -170,6 +171,16 @@ class FileTreeResponse(BaseModel):
|
|
|
170
171
|
entries: list[FileTreeEntry]
|
|
171
172
|
|
|
172
173
|
|
|
174
|
+
class FileSearchResponse(BaseModel):
|
|
175
|
+
"""Response for GET /files/search."""
|
|
176
|
+
|
|
177
|
+
root_path: str
|
|
178
|
+
path: str
|
|
179
|
+
query: str
|
|
180
|
+
entries: list[FileTreeEntry]
|
|
181
|
+
truncated: bool = False
|
|
182
|
+
|
|
183
|
+
|
|
173
184
|
class FileReadResponse(BaseModel):
|
|
174
185
|
"""Response for GET /files/read."""
|
|
175
186
|
|
|
@@ -180,6 +191,15 @@ class FileReadResponse(BaseModel):
|
|
|
180
191
|
readonly: bool = False
|
|
181
192
|
|
|
182
193
|
|
|
194
|
+
class FileDiffResponse(BaseModel):
|
|
195
|
+
"""Response for GET /files/diff."""
|
|
196
|
+
|
|
197
|
+
path: str
|
|
198
|
+
status: str
|
|
199
|
+
original_content: str
|
|
200
|
+
current_content: str
|
|
201
|
+
|
|
202
|
+
|
|
183
203
|
class FileWriteRequest(BaseModel):
|
|
184
204
|
"""Request body for POST /files/write."""
|
|
185
205
|
|
|
@@ -203,6 +223,17 @@ class FileRenameRequest(BaseModel):
|
|
|
203
223
|
new_name: str
|
|
204
224
|
|
|
205
225
|
|
|
226
|
+
class FileUploadResponse(BaseModel):
|
|
227
|
+
"""Response for POST /files/upload."""
|
|
228
|
+
|
|
229
|
+
name: str
|
|
230
|
+
path: str
|
|
231
|
+
size: int
|
|
232
|
+
overwritten: bool = False
|
|
233
|
+
success: bool
|
|
234
|
+
message: str
|
|
235
|
+
|
|
236
|
+
|
|
206
237
|
class FileOperationResponse(BaseModel):
|
|
207
238
|
"""Generic file operation response."""
|
|
208
239
|
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 ROBOTIS CO., LTD.
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
#
|
|
17
|
+
# Author: Hyungyu Kim
|
|
18
|
+
|
|
19
|
+
"""Host file browser and editor endpoints."""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import uuid
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from cyclo_host_agent.models import (
|
|
28
|
+
FileCreateRequest,
|
|
29
|
+
FileDiffResponse,
|
|
30
|
+
FileOperationResponse,
|
|
31
|
+
FileReadResponse,
|
|
32
|
+
FileRenameRequest,
|
|
33
|
+
FileSearchResponse,
|
|
34
|
+
FileTreeEntry,
|
|
35
|
+
FileTreeResponse,
|
|
36
|
+
FileUploadResponse,
|
|
37
|
+
FileWriteRequest,
|
|
38
|
+
)
|
|
39
|
+
from fastapi import APIRouter, HTTPException, Query, Request, status
|
|
40
|
+
|
|
41
|
+
router = APIRouter(prefix='/files', tags=['files'])
|
|
42
|
+
|
|
43
|
+
MAX_READ_BYTES = 2 * 1024 * 1024
|
|
44
|
+
MAX_WRITE_BYTES = 4 * 1024 * 1024
|
|
45
|
+
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
|
46
|
+
MAX_SEARCH_RESULTS = 200
|
|
47
|
+
MAX_SEARCH_VISITS = 20000
|
|
48
|
+
GIT_STATUS_TIMEOUT_SECONDS = 0.5
|
|
49
|
+
GIT_DIFF_TIMEOUT_SECONDS = 2.0
|
|
50
|
+
MAX_DIFF_BYTES = 2 * 1024 * 1024
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_file_root() -> Path:
|
|
54
|
+
configured_workspace = os.environ.get('CYCLO_HOST_AGENT_WORKSPACE')
|
|
55
|
+
if configured_workspace:
|
|
56
|
+
return Path(configured_workspace).expanduser().resolve()
|
|
57
|
+
sudo_user = os.environ.get('SUDO_USER')
|
|
58
|
+
if sudo_user:
|
|
59
|
+
return Path(f'/home/{sudo_user}').resolve()
|
|
60
|
+
return Path.home().resolve()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
FILE_ROOT_PATH = _resolve_file_root()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _safe_path(relative_path: str = '') -> tuple[Path, str]:
|
|
67
|
+
target = (FILE_ROOT_PATH / relative_path).resolve()
|
|
68
|
+
if target != FILE_ROOT_PATH and FILE_ROOT_PATH not in target.parents:
|
|
69
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
|
|
70
|
+
rel = '' if target == FILE_ROOT_PATH else target.relative_to(FILE_ROOT_PATH).as_posix()
|
|
71
|
+
return target, rel
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _safe_filename(filename: str) -> str:
|
|
75
|
+
name = Path(filename).name
|
|
76
|
+
if name in {'', '.', '..'} or name != filename or '/' in filename or '\\' in filename:
|
|
77
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid filename')
|
|
78
|
+
return name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_binary(path: Path) -> bool:
|
|
82
|
+
try:
|
|
83
|
+
return b'\x00' in path.read_bytes()[:8192]
|
|
84
|
+
except OSError:
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _entry(path: Path, git_status: str | None = None) -> FileTreeEntry:
|
|
89
|
+
stat = path.lstat()
|
|
90
|
+
return FileTreeEntry(
|
|
91
|
+
name=path.name,
|
|
92
|
+
path='' if path == FILE_ROOT_PATH else path.relative_to(FILE_ROOT_PATH).as_posix(),
|
|
93
|
+
type='directory' if path.is_dir() else 'file',
|
|
94
|
+
size=None if path.is_dir() else stat.st_size,
|
|
95
|
+
modified=stat.st_mtime,
|
|
96
|
+
readonly=not os.access(path, os.W_OK),
|
|
97
|
+
hidden=path.name.startswith('.'),
|
|
98
|
+
symlink=path.is_symlink(),
|
|
99
|
+
git_status=git_status,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _run_git_text(
|
|
104
|
+
args: list[str],
|
|
105
|
+
cwd: Path,
|
|
106
|
+
timeout: float = GIT_STATUS_TIMEOUT_SECONDS,
|
|
107
|
+
) -> subprocess.CompletedProcess[str] | None:
|
|
108
|
+
env = os.environ.copy()
|
|
109
|
+
env['GIT_OPTIONAL_LOCKS'] = '0'
|
|
110
|
+
try:
|
|
111
|
+
return subprocess.run(
|
|
112
|
+
['git', *args],
|
|
113
|
+
cwd=cwd,
|
|
114
|
+
env=env,
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
encoding='utf-8',
|
|
118
|
+
errors='replace',
|
|
119
|
+
timeout=timeout,
|
|
120
|
+
check=False,
|
|
121
|
+
)
|
|
122
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run_git_bytes(args: list[str], cwd: Path) -> subprocess.CompletedProcess[bytes] | None:
|
|
127
|
+
env = os.environ.copy()
|
|
128
|
+
env['GIT_OPTIONAL_LOCKS'] = '0'
|
|
129
|
+
try:
|
|
130
|
+
return subprocess.run(
|
|
131
|
+
['git', *args],
|
|
132
|
+
cwd=cwd,
|
|
133
|
+
env=env,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
timeout=GIT_STATUS_TIMEOUT_SECONDS,
|
|
136
|
+
check=False,
|
|
137
|
+
)
|
|
138
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _merge_git_status(current: str | None, incoming: str) -> str:
|
|
143
|
+
if current == 'modified' or incoming == 'modified':
|
|
144
|
+
return 'modified'
|
|
145
|
+
return incoming
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _git_status_by_entry_path(directory: Path, entries: list[FileTreeEntry]) -> dict[str, str]:
|
|
149
|
+
if not entries:
|
|
150
|
+
return {}
|
|
151
|
+
|
|
152
|
+
root_result = _run_git_text(['rev-parse', '--show-toplevel'], directory)
|
|
153
|
+
if not root_result or root_result.returncode != 0:
|
|
154
|
+
return {}
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
repo_root = Path(root_result.stdout.strip()).resolve()
|
|
158
|
+
directory_rel = '' if directory == repo_root else directory.relative_to(repo_root).as_posix()
|
|
159
|
+
except (OSError, ValueError):
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
entry_by_repo_path: dict[str, str] = {}
|
|
163
|
+
for entry in entries:
|
|
164
|
+
try:
|
|
165
|
+
entry_abs = (FILE_ROOT_PATH / entry.path).resolve()
|
|
166
|
+
entry_repo_path = entry_abs.relative_to(repo_root).as_posix()
|
|
167
|
+
except (OSError, ValueError):
|
|
168
|
+
continue
|
|
169
|
+
entry_by_repo_path[entry_repo_path] = entry.path
|
|
170
|
+
|
|
171
|
+
pathspec = '.' if not directory_rel else directory_rel
|
|
172
|
+
status_result = _run_git_bytes(
|
|
173
|
+
['status', '--porcelain=v1', '-z', '--untracked-files=normal', '--', pathspec],
|
|
174
|
+
repo_root,
|
|
175
|
+
)
|
|
176
|
+
if not status_result or status_result.returncode != 0:
|
|
177
|
+
return {}
|
|
178
|
+
|
|
179
|
+
statuses: dict[str, str] = {}
|
|
180
|
+
records = status_result.stdout.split(b'\0')
|
|
181
|
+
index = 0
|
|
182
|
+
while index < len(records):
|
|
183
|
+
record = records[index]
|
|
184
|
+
index += 1
|
|
185
|
+
if len(record) < 4:
|
|
186
|
+
continue
|
|
187
|
+
raw_status = record[:2].decode('ascii', errors='replace')
|
|
188
|
+
git_path = record[3:].decode('utf-8', errors='surrogateescape')
|
|
189
|
+
if raw_status[0] in {'R', 'C'} or raw_status[1] in {'R', 'C'}:
|
|
190
|
+
index += 1
|
|
191
|
+
|
|
192
|
+
if directory_rel:
|
|
193
|
+
if git_path == directory_rel:
|
|
194
|
+
continue
|
|
195
|
+
prefix = f'{directory_rel}/'
|
|
196
|
+
if not git_path.startswith(prefix):
|
|
197
|
+
continue
|
|
198
|
+
remainder = git_path[len(prefix):]
|
|
199
|
+
else:
|
|
200
|
+
remainder = git_path
|
|
201
|
+
|
|
202
|
+
child_name = remainder.split('/', 1)[0]
|
|
203
|
+
child_repo_path = f'{directory_rel}/{child_name}' if directory_rel else child_name
|
|
204
|
+
entry_path = entry_by_repo_path.get(child_repo_path)
|
|
205
|
+
if not entry_path:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
status_value = 'untracked' if raw_status == '??' else 'modified'
|
|
209
|
+
statuses[entry_path] = _merge_git_status(statuses.get(entry_path), status_value)
|
|
210
|
+
|
|
211
|
+
return statuses
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _git_root_for_path(path: Path) -> Path | None:
|
|
215
|
+
cwd = path if path.is_dir() else path.parent
|
|
216
|
+
result = _run_git_text(['rev-parse', '--show-toplevel'], cwd)
|
|
217
|
+
if not result or result.returncode != 0:
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
return Path(result.stdout.strip()).resolve()
|
|
221
|
+
except OSError:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _git_status_for_file(repo_root: Path, repo_path: str) -> str | None:
|
|
226
|
+
result = _run_git_bytes(
|
|
227
|
+
['status', '--porcelain=v1', '-z', '--untracked-files=normal', '--', repo_path],
|
|
228
|
+
repo_root,
|
|
229
|
+
)
|
|
230
|
+
if not result or result.returncode != 0:
|
|
231
|
+
return None
|
|
232
|
+
first_record = result.stdout.split(b'\0', 1)[0]
|
|
233
|
+
if len(first_record) < 4:
|
|
234
|
+
return None
|
|
235
|
+
raw_status = first_record[:2].decode('ascii', errors='replace')
|
|
236
|
+
return 'untracked' if raw_status == '??' else 'modified'
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _read_text_file_for_diff(path: Path) -> str:
|
|
240
|
+
stat = path.stat()
|
|
241
|
+
if stat.st_size > MAX_DIFF_BYTES:
|
|
242
|
+
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='File is too large to diff')
|
|
243
|
+
if _is_binary(path):
|
|
244
|
+
raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail='Binary files are not diffable')
|
|
245
|
+
try:
|
|
246
|
+
return path.read_text(encoding='utf-8', errors='replace')
|
|
247
|
+
except OSError as exc:
|
|
248
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _git_head_content(repo_root: Path, repo_path: str) -> str:
|
|
252
|
+
result = _run_git_text(
|
|
253
|
+
['show', f'HEAD:{repo_path}'],
|
|
254
|
+
repo_root,
|
|
255
|
+
timeout=GIT_DIFF_TIMEOUT_SECONDS,
|
|
256
|
+
)
|
|
257
|
+
if not result or result.returncode != 0:
|
|
258
|
+
return ''
|
|
259
|
+
if len(result.stdout.encode('utf-8')) > MAX_DIFF_BYTES:
|
|
260
|
+
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='Original file is too large to diff')
|
|
261
|
+
return result.stdout
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@router.get('/tree', response_model=FileTreeResponse)
|
|
265
|
+
async def list_directory(
|
|
266
|
+
path: str = '',
|
|
267
|
+
show_hidden: bool = Query(False),
|
|
268
|
+
) -> FileTreeResponse:
|
|
269
|
+
"""Return one directory level under the configured file root."""
|
|
270
|
+
target, rel = _safe_path(path)
|
|
271
|
+
if not target.is_dir():
|
|
272
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a directory')
|
|
273
|
+
|
|
274
|
+
entries: list[FileTreeEntry] = []
|
|
275
|
+
for child in sorted(target.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
|
|
276
|
+
if not show_hidden and child.name.startswith('.'):
|
|
277
|
+
continue
|
|
278
|
+
try:
|
|
279
|
+
entries.append(_entry(child))
|
|
280
|
+
except OSError:
|
|
281
|
+
continue
|
|
282
|
+
git_statuses = _git_status_by_entry_path(target, entries)
|
|
283
|
+
for entry in entries:
|
|
284
|
+
entry.git_status = git_statuses.get(entry.path)
|
|
285
|
+
return FileTreeResponse(root_path=str(FILE_ROOT_PATH), path=rel, entries=entries)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@router.get('/search', response_model=FileSearchResponse)
|
|
289
|
+
async def search_files(
|
|
290
|
+
query: str,
|
|
291
|
+
path: str = '',
|
|
292
|
+
show_hidden: bool = Query(False),
|
|
293
|
+
limit: int = Query(MAX_SEARCH_RESULTS, ge=1, le=1000),
|
|
294
|
+
) -> FileSearchResponse:
|
|
295
|
+
"""Search file and folder names recursively under the current directory."""
|
|
296
|
+
target, rel = _safe_path(path)
|
|
297
|
+
if not target.is_dir():
|
|
298
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a directory')
|
|
299
|
+
normalized_query = query.strip().lower()
|
|
300
|
+
if not normalized_query:
|
|
301
|
+
return FileSearchResponse(root_path=str(FILE_ROOT_PATH), path=rel, query=query, entries=[])
|
|
302
|
+
|
|
303
|
+
entries: list[FileTreeEntry] = []
|
|
304
|
+
visits = 0
|
|
305
|
+
truncated = False
|
|
306
|
+
stack = [target]
|
|
307
|
+
while stack:
|
|
308
|
+
current = stack.pop()
|
|
309
|
+
try:
|
|
310
|
+
children = sorted(current.iterdir(), key=lambda item: item.name.lower(), reverse=True)
|
|
311
|
+
except OSError:
|
|
312
|
+
continue
|
|
313
|
+
for child in children:
|
|
314
|
+
visits += 1
|
|
315
|
+
if visits > MAX_SEARCH_VISITS:
|
|
316
|
+
truncated = True
|
|
317
|
+
stack.clear()
|
|
318
|
+
break
|
|
319
|
+
if not show_hidden and child.name.startswith('.'):
|
|
320
|
+
continue
|
|
321
|
+
if normalized_query in child.name.lower():
|
|
322
|
+
try:
|
|
323
|
+
entries.append(_entry(child))
|
|
324
|
+
except OSError:
|
|
325
|
+
pass
|
|
326
|
+
if len(entries) >= limit:
|
|
327
|
+
truncated = True
|
|
328
|
+
stack.clear()
|
|
329
|
+
break
|
|
330
|
+
if child.is_dir() and not child.is_symlink():
|
|
331
|
+
stack.append(child)
|
|
332
|
+
|
|
333
|
+
return FileSearchResponse(
|
|
334
|
+
root_path=str(FILE_ROOT_PATH),
|
|
335
|
+
path=rel,
|
|
336
|
+
query=query,
|
|
337
|
+
entries=entries,
|
|
338
|
+
truncated=truncated,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@router.get('/read', response_model=FileReadResponse)
|
|
343
|
+
async def read_file(
|
|
344
|
+
path: str,
|
|
345
|
+
) -> FileReadResponse:
|
|
346
|
+
"""Read a UTF-8 text file under the configured file root."""
|
|
347
|
+
target, rel = _safe_path(path)
|
|
348
|
+
if not target.is_file():
|
|
349
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a file')
|
|
350
|
+
stat = target.stat()
|
|
351
|
+
if stat.st_size > MAX_READ_BYTES:
|
|
352
|
+
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='File is too large to edit')
|
|
353
|
+
if _is_binary(target):
|
|
354
|
+
raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail='Binary files are not editable')
|
|
355
|
+
try:
|
|
356
|
+
content = target.read_text(encoding='utf-8', errors='replace')
|
|
357
|
+
except OSError as exc:
|
|
358
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
359
|
+
return FileReadResponse(
|
|
360
|
+
path=rel,
|
|
361
|
+
content=content,
|
|
362
|
+
size=stat.st_size,
|
|
363
|
+
modified=stat.st_mtime,
|
|
364
|
+
readonly=not os.access(target, os.W_OK),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@router.get('/diff', response_model=FileDiffResponse)
|
|
369
|
+
async def diff_file(
|
|
370
|
+
path: str,
|
|
371
|
+
) -> FileDiffResponse:
|
|
372
|
+
"""Return original and current file content for a git diff view."""
|
|
373
|
+
target, rel = _safe_path(path)
|
|
374
|
+
if not target.is_file():
|
|
375
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a file')
|
|
376
|
+
|
|
377
|
+
repo_root = _git_root_for_path(target)
|
|
378
|
+
if not repo_root:
|
|
379
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not in a git repository')
|
|
380
|
+
try:
|
|
381
|
+
repo_path = target.relative_to(repo_root).as_posix()
|
|
382
|
+
except ValueError:
|
|
383
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not in a git repository')
|
|
384
|
+
|
|
385
|
+
git_status = _git_status_for_file(repo_root, repo_path)
|
|
386
|
+
if not git_status:
|
|
387
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='File has no git changes')
|
|
388
|
+
|
|
389
|
+
current_content = _read_text_file_for_diff(target)
|
|
390
|
+
original_content = '' if git_status == 'untracked' else _git_head_content(repo_root, repo_path)
|
|
391
|
+
return FileDiffResponse(
|
|
392
|
+
path=rel,
|
|
393
|
+
status=git_status,
|
|
394
|
+
original_content=original_content,
|
|
395
|
+
current_content=current_content,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@router.post('/write', response_model=FileOperationResponse)
|
|
400
|
+
async def write_file(req: FileWriteRequest) -> FileOperationResponse:
|
|
401
|
+
"""Write a UTF-8 text file under the configured file root."""
|
|
402
|
+
target, rel = _safe_path(req.path)
|
|
403
|
+
if target.exists() and not target.is_file():
|
|
404
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a file')
|
|
405
|
+
if len(req.content.encode('utf-8')) > MAX_WRITE_BYTES:
|
|
406
|
+
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='Content is too large')
|
|
407
|
+
if req.expected_modified is not None and target.exists():
|
|
408
|
+
current_modified = target.stat().st_mtime
|
|
409
|
+
if abs(current_modified - req.expected_modified) > 0.001:
|
|
410
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='File changed on disk')
|
|
411
|
+
try:
|
|
412
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
413
|
+
target.write_text(req.content, encoding='utf-8')
|
|
414
|
+
except OSError as exc:
|
|
415
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
416
|
+
return FileOperationResponse(path=rel, success=True, message='Saved')
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@router.post('/create', response_model=FileOperationResponse)
|
|
420
|
+
async def create_path(req: FileCreateRequest) -> FileOperationResponse:
|
|
421
|
+
"""Create a file or folder under the configured file root."""
|
|
422
|
+
target, rel = _safe_path(req.path)
|
|
423
|
+
if target.exists():
|
|
424
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Path already exists')
|
|
425
|
+
try:
|
|
426
|
+
if req.type == 'directory':
|
|
427
|
+
target.mkdir(parents=True)
|
|
428
|
+
message = 'Folder created'
|
|
429
|
+
elif req.type == 'file':
|
|
430
|
+
if len(req.content.encode('utf-8')) > MAX_WRITE_BYTES:
|
|
431
|
+
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='Content is too large')
|
|
432
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
433
|
+
target.write_text(req.content, encoding='utf-8')
|
|
434
|
+
message = 'File created'
|
|
435
|
+
else:
|
|
436
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Type must be file or directory')
|
|
437
|
+
except OSError as exc:
|
|
438
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
439
|
+
return FileOperationResponse(path=rel, success=True, message=message)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@router.post('/rename', response_model=FileOperationResponse)
|
|
443
|
+
async def rename_path(req: FileRenameRequest) -> FileOperationResponse:
|
|
444
|
+
"""Rename a file or folder under the configured file root."""
|
|
445
|
+
target, _ = _safe_path(req.path)
|
|
446
|
+
if not target.exists():
|
|
447
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
|
|
448
|
+
if '/' in req.new_name or '\\' in req.new_name or req.new_name in {'', '.', '..'}:
|
|
449
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid name')
|
|
450
|
+
destination = (target.parent / req.new_name).resolve()
|
|
451
|
+
if destination != FILE_ROOT_PATH and FILE_ROOT_PATH not in destination.parents:
|
|
452
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
|
|
453
|
+
if destination.exists():
|
|
454
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Destination already exists')
|
|
455
|
+
try:
|
|
456
|
+
target.rename(destination)
|
|
457
|
+
except OSError as exc:
|
|
458
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
459
|
+
rel = '' if destination == FILE_ROOT_PATH else destination.relative_to(FILE_ROOT_PATH).as_posix()
|
|
460
|
+
return FileOperationResponse(path=rel, success=True, message='Renamed')
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@router.post('/upload', response_model=FileUploadResponse)
|
|
464
|
+
async def upload_file(
|
|
465
|
+
request: Request,
|
|
466
|
+
path: str = '',
|
|
467
|
+
filename: str = Query(...),
|
|
468
|
+
overwrite: bool = False,
|
|
469
|
+
) -> FileUploadResponse:
|
|
470
|
+
"""Upload one file into a directory under the configured file root."""
|
|
471
|
+
target_dir, _ = _safe_path(path)
|
|
472
|
+
if not target_dir.is_dir():
|
|
473
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a directory')
|
|
474
|
+
|
|
475
|
+
safe_name = _safe_filename(filename)
|
|
476
|
+
destination = (target_dir / safe_name).resolve()
|
|
477
|
+
if destination != FILE_ROOT_PATH and FILE_ROOT_PATH not in destination.parents:
|
|
478
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
|
|
479
|
+
if destination.exists() and destination.is_dir():
|
|
480
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='A folder with that name already exists')
|
|
481
|
+
overwritten = destination.exists()
|
|
482
|
+
if overwritten and not overwrite:
|
|
483
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='File already exists')
|
|
484
|
+
|
|
485
|
+
temp_path = target_dir / f'.{safe_name}.{uuid.uuid4().hex}.uploading'
|
|
486
|
+
size_bytes = 0
|
|
487
|
+
try:
|
|
488
|
+
with temp_path.open('wb') as output:
|
|
489
|
+
async for chunk in request.stream():
|
|
490
|
+
size_bytes += len(chunk)
|
|
491
|
+
if size_bytes > MAX_UPLOAD_BYTES:
|
|
492
|
+
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='File is too large to upload')
|
|
493
|
+
output.write(chunk)
|
|
494
|
+
os.replace(temp_path, destination)
|
|
495
|
+
except HTTPException:
|
|
496
|
+
try:
|
|
497
|
+
temp_path.unlink(missing_ok=True)
|
|
498
|
+
except OSError:
|
|
499
|
+
pass
|
|
500
|
+
raise
|
|
501
|
+
except OSError as exc:
|
|
502
|
+
try:
|
|
503
|
+
temp_path.unlink(missing_ok=True)
|
|
504
|
+
except OSError:
|
|
505
|
+
pass
|
|
506
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
507
|
+
|
|
508
|
+
rel = destination.relative_to(FILE_ROOT_PATH).as_posix()
|
|
509
|
+
return FileUploadResponse(
|
|
510
|
+
name=safe_name,
|
|
511
|
+
path=rel,
|
|
512
|
+
size=size_bytes,
|
|
513
|
+
overwritten=overwritten,
|
|
514
|
+
success=True,
|
|
515
|
+
message='Uploaded',
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@router.delete('', response_model=FileOperationResponse)
|
|
520
|
+
async def delete_path(
|
|
521
|
+
path: str,
|
|
522
|
+
recursive: bool = Query(False),
|
|
523
|
+
) -> FileOperationResponse:
|
|
524
|
+
"""Delete a file or folder under the configured file root."""
|
|
525
|
+
target, rel = _safe_path(path)
|
|
526
|
+
if target == FILE_ROOT_PATH:
|
|
527
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Cannot delete file root')
|
|
528
|
+
if not target.exists():
|
|
529
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
|
|
530
|
+
try:
|
|
531
|
+
if target.is_dir():
|
|
532
|
+
if recursive:
|
|
533
|
+
shutil.rmtree(target)
|
|
534
|
+
else:
|
|
535
|
+
target.rmdir()
|
|
536
|
+
else:
|
|
537
|
+
target.unlink()
|
|
538
|
+
except OSError as exc:
|
|
539
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
540
|
+
return FileOperationResponse(path=rel, success=True, message='Deleted')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cyclo-manager
|
|
3
|
-
Version: 1.1.0.
|
|
3
|
+
Version: 1.1.0.dev2
|
|
4
4
|
Summary: cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack.
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -137,7 +137,7 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
|
|
|
137
137
|
|
|
138
138
|
`cyclo_manager up` installs the `refresh-host-agent` CLI subcommand into sudoers. It is registered as `sys.executable -m cyclo_manager_cli.cli refresh-host-agent` with `SETENV`, so UI-triggered updates can pass the current `PYTHONPATH` to sudo and do not depend on `PATH`. The command is idempotent and refreshes the socket directory, sudoers file, systemd unit, and service state after package upgrades.
|
|
139
139
|
|
|
140
|
-
Repository scanning
|
|
140
|
+
Repository scanning and the Files UI use `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise they use the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically. Only repositories whose `origin` remote belongs to `ROBOTIS-GIT` are returned by the repo list/update endpoints.
|
|
141
141
|
|
|
142
142
|
---
|
|
143
143
|
|
|
@@ -217,8 +217,7 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
|
|
|
217
217
|
| Variable | Set by | Purpose |
|
|
218
218
|
|----------|--------|---------|
|
|
219
219
|
| **`CYCLO_MANAGER_CONFIG_FILE`** | CLI (`up`, `down`, `update`) | Absolute path to bundled `config.yml` on the host; mounted into the API container |
|
|
220
|
-
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories |
|
|
221
|
-
| **`CYCLO_HOST_AGENT_FILE_ROOT`** | User (optional) | Root directory for the Files UI/API. Defaults to the host agent service user's home (`$HOME`, e.g. `/root` when running as root) |
|
|
220
|
+
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories and used as the Files UI/API root |
|
|
222
221
|
| **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
|
|
223
222
|
| **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
|
|
224
223
|
| **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cyclo-manager"
|
|
7
|
-
version = "1.1.0.
|
|
7
|
+
version = "1.1.0.dev2"
|
|
8
8
|
description = "cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
#
|
|
3
|
-
# Copyright 2026 ROBOTIS CO., LTD.
|
|
4
|
-
#
|
|
5
|
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
-
# you may not use this file except in compliance with the License.
|
|
7
|
-
# You may obtain a copy of the License at
|
|
8
|
-
#
|
|
9
|
-
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
-
#
|
|
11
|
-
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
-
# See the License for the specific language governing permissions and
|
|
15
|
-
# limitations under the License.
|
|
16
|
-
#
|
|
17
|
-
# Author: Hyungyu Kim
|
|
18
|
-
|
|
19
|
-
"""Host file browser and editor endpoints."""
|
|
20
|
-
|
|
21
|
-
import os
|
|
22
|
-
import shutil
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
|
|
25
|
-
from cyclo_host_agent.models import (
|
|
26
|
-
FileCreateRequest,
|
|
27
|
-
FileOperationResponse,
|
|
28
|
-
FileReadResponse,
|
|
29
|
-
FileRenameRequest,
|
|
30
|
-
FileTreeEntry,
|
|
31
|
-
FileTreeResponse,
|
|
32
|
-
FileWriteRequest,
|
|
33
|
-
)
|
|
34
|
-
from fastapi import APIRouter, HTTPException, Query, status
|
|
35
|
-
|
|
36
|
-
router = APIRouter(prefix='/files', tags=['files'])
|
|
37
|
-
|
|
38
|
-
MAX_READ_BYTES = 2 * 1024 * 1024
|
|
39
|
-
MAX_WRITE_BYTES = 4 * 1024 * 1024
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _resolve_file_root() -> Path:
|
|
43
|
-
configured_root = os.environ.get('CYCLO_HOST_AGENT_FILE_ROOT')
|
|
44
|
-
if configured_root:
|
|
45
|
-
return Path(configured_root).expanduser().resolve()
|
|
46
|
-
sudo_user = os.environ.get('SUDO_USER')
|
|
47
|
-
if sudo_user:
|
|
48
|
-
return Path(f'/home/{sudo_user}').resolve()
|
|
49
|
-
return Path.home().resolve()
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
FILE_ROOT_PATH = _resolve_file_root()
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _safe_path(relative_path: str = '') -> tuple[Path, str]:
|
|
56
|
-
target = (FILE_ROOT_PATH / relative_path).resolve()
|
|
57
|
-
if target != FILE_ROOT_PATH and FILE_ROOT_PATH not in target.parents:
|
|
58
|
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
|
|
59
|
-
rel = '' if target == FILE_ROOT_PATH else target.relative_to(FILE_ROOT_PATH).as_posix()
|
|
60
|
-
return target, rel
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _is_binary(path: Path) -> bool:
|
|
64
|
-
try:
|
|
65
|
-
return b'\x00' in path.read_bytes()[:8192]
|
|
66
|
-
except OSError:
|
|
67
|
-
return True
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _entry(path: Path) -> FileTreeEntry:
|
|
71
|
-
stat = path.lstat()
|
|
72
|
-
return FileTreeEntry(
|
|
73
|
-
name=path.name,
|
|
74
|
-
path='' if path == FILE_ROOT_PATH else path.relative_to(FILE_ROOT_PATH).as_posix(),
|
|
75
|
-
type='directory' if path.is_dir() else 'file',
|
|
76
|
-
size=None if path.is_dir() else stat.st_size,
|
|
77
|
-
modified=stat.st_mtime,
|
|
78
|
-
readonly=not os.access(path, os.W_OK),
|
|
79
|
-
hidden=path.name.startswith('.'),
|
|
80
|
-
symlink=path.is_symlink(),
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
@router.get('/tree', response_model=FileTreeResponse)
|
|
85
|
-
async def list_directory(
|
|
86
|
-
path: str = '',
|
|
87
|
-
show_hidden: bool = Query(False),
|
|
88
|
-
) -> FileTreeResponse:
|
|
89
|
-
"""Return one directory level under the configured file root."""
|
|
90
|
-
target, rel = _safe_path(path)
|
|
91
|
-
if not target.is_dir():
|
|
92
|
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a directory')
|
|
93
|
-
|
|
94
|
-
entries: list[FileTreeEntry] = []
|
|
95
|
-
for child in sorted(target.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
|
|
96
|
-
if not show_hidden and child.name.startswith('.'):
|
|
97
|
-
continue
|
|
98
|
-
try:
|
|
99
|
-
entries.append(_entry(child))
|
|
100
|
-
except OSError:
|
|
101
|
-
continue
|
|
102
|
-
return FileTreeResponse(root_path=str(FILE_ROOT_PATH), path=rel, entries=entries)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
@router.get('/read', response_model=FileReadResponse)
|
|
106
|
-
async def read_file(
|
|
107
|
-
path: str,
|
|
108
|
-
) -> FileReadResponse:
|
|
109
|
-
"""Read a UTF-8 text file under the configured file root."""
|
|
110
|
-
target, rel = _safe_path(path)
|
|
111
|
-
if not target.is_file():
|
|
112
|
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a file')
|
|
113
|
-
stat = target.stat()
|
|
114
|
-
if stat.st_size > MAX_READ_BYTES:
|
|
115
|
-
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='File is too large to edit')
|
|
116
|
-
if _is_binary(target):
|
|
117
|
-
raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail='Binary files are not editable')
|
|
118
|
-
try:
|
|
119
|
-
content = target.read_text(encoding='utf-8', errors='replace')
|
|
120
|
-
except OSError as exc:
|
|
121
|
-
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
122
|
-
return FileReadResponse(
|
|
123
|
-
path=rel,
|
|
124
|
-
content=content,
|
|
125
|
-
size=stat.st_size,
|
|
126
|
-
modified=stat.st_mtime,
|
|
127
|
-
readonly=not os.access(target, os.W_OK),
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
@router.post('/write', response_model=FileOperationResponse)
|
|
132
|
-
async def write_file(req: FileWriteRequest) -> FileOperationResponse:
|
|
133
|
-
"""Write a UTF-8 text file under the configured file root."""
|
|
134
|
-
target, rel = _safe_path(req.path)
|
|
135
|
-
if target.exists() and not target.is_file():
|
|
136
|
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a file')
|
|
137
|
-
if len(req.content.encode('utf-8')) > MAX_WRITE_BYTES:
|
|
138
|
-
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='Content is too large')
|
|
139
|
-
if req.expected_modified is not None and target.exists():
|
|
140
|
-
current_modified = target.stat().st_mtime
|
|
141
|
-
if abs(current_modified - req.expected_modified) > 0.001:
|
|
142
|
-
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='File changed on disk')
|
|
143
|
-
try:
|
|
144
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
-
target.write_text(req.content, encoding='utf-8')
|
|
146
|
-
except OSError as exc:
|
|
147
|
-
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
148
|
-
return FileOperationResponse(path=rel, success=True, message='Saved')
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
@router.post('/create', response_model=FileOperationResponse)
|
|
152
|
-
async def create_path(req: FileCreateRequest) -> FileOperationResponse:
|
|
153
|
-
"""Create a file or folder under the configured file root."""
|
|
154
|
-
target, rel = _safe_path(req.path)
|
|
155
|
-
if target.exists():
|
|
156
|
-
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Path already exists')
|
|
157
|
-
try:
|
|
158
|
-
if req.type == 'directory':
|
|
159
|
-
target.mkdir(parents=True)
|
|
160
|
-
message = 'Folder created'
|
|
161
|
-
elif req.type == 'file':
|
|
162
|
-
if len(req.content.encode('utf-8')) > MAX_WRITE_BYTES:
|
|
163
|
-
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='Content is too large')
|
|
164
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
-
target.write_text(req.content, encoding='utf-8')
|
|
166
|
-
message = 'File created'
|
|
167
|
-
else:
|
|
168
|
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Type must be file or directory')
|
|
169
|
-
except OSError as exc:
|
|
170
|
-
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
171
|
-
return FileOperationResponse(path=rel, success=True, message=message)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
@router.post('/rename', response_model=FileOperationResponse)
|
|
175
|
-
async def rename_path(req: FileRenameRequest) -> FileOperationResponse:
|
|
176
|
-
"""Rename a file or folder under the configured file root."""
|
|
177
|
-
target, _ = _safe_path(req.path)
|
|
178
|
-
if not target.exists():
|
|
179
|
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
|
|
180
|
-
if '/' in req.new_name or '\\' in req.new_name or req.new_name in {'', '.', '..'}:
|
|
181
|
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid name')
|
|
182
|
-
destination = (target.parent / req.new_name).resolve()
|
|
183
|
-
if destination != FILE_ROOT_PATH and FILE_ROOT_PATH not in destination.parents:
|
|
184
|
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
|
|
185
|
-
if destination.exists():
|
|
186
|
-
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Destination already exists')
|
|
187
|
-
try:
|
|
188
|
-
target.rename(destination)
|
|
189
|
-
except OSError as exc:
|
|
190
|
-
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
191
|
-
rel = '' if destination == FILE_ROOT_PATH else destination.relative_to(FILE_ROOT_PATH).as_posix()
|
|
192
|
-
return FileOperationResponse(path=rel, success=True, message='Renamed')
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
@router.delete('', response_model=FileOperationResponse)
|
|
196
|
-
async def delete_path(
|
|
197
|
-
path: str,
|
|
198
|
-
recursive: bool = Query(False),
|
|
199
|
-
) -> FileOperationResponse:
|
|
200
|
-
"""Delete a file or folder under the configured file root."""
|
|
201
|
-
target, rel = _safe_path(path)
|
|
202
|
-
if target == FILE_ROOT_PATH:
|
|
203
|
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Cannot delete file root')
|
|
204
|
-
if not target.exists():
|
|
205
|
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
|
|
206
|
-
try:
|
|
207
|
-
if target.is_dir():
|
|
208
|
-
if recursive:
|
|
209
|
-
shutil.rmtree(target)
|
|
210
|
-
else:
|
|
211
|
-
target.rmdir()
|
|
212
|
-
else:
|
|
213
|
-
target.unlink()
|
|
214
|
-
except OSError as exc:
|
|
215
|
-
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
216
|
-
return FileOperationResponse(path=rel, success=True, message='Deleted')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/routers/system_stats.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager_cli/docker/docker-compose.yml
RENAMED
|
File without changes
|
|
File without changes
|