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.
Files changed (23) hide show
  1. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/PKG-INFO +3 -4
  2. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/README.md +2 -3
  3. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/models.py +31 -0
  4. cyclo_manager-1.1.0.dev2/cyclo_host_agent/routers/files.py +540 -0
  5. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/PKG-INFO +3 -4
  6. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/pyproject.toml +1 -1
  7. cyclo_manager-1.1.0.dev1/cyclo_host_agent/routers/files.py +0 -216
  8. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/__init__.py +0 -0
  9. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/main.py +0 -0
  10. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/routers/__init__.py +0 -0
  11. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/routers/repos.py +0 -0
  12. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/routers/system_stats.py +0 -0
  13. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_host_agent/routers/update.py +0 -0
  14. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/SOURCES.txt +0 -0
  15. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/dependency_links.txt +0 -0
  16. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/entry_points.txt +0 -0
  17. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/requires.txt +0 -0
  18. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager.egg-info/top_level.txt +0 -0
  19. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager_cli/__init__.py +0 -0
  20. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager_cli/cli.py +0 -0
  21. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager_cli/config/config.yml +0 -0
  22. {cyclo_manager-1.1.0.dev1 → cyclo_manager-1.1.0.dev2}/cyclo_manager_cli/docker/docker-compose.yml +0 -0
  23. {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.dev1
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 uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses 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.
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 uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses 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.
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.dev1
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 uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses 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.
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.dev1"
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')