cyclo-manager 0.1.1.dev0__tar.gz → 0.2.0.dev0__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-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/PKG-INFO +4 -1
- cyclo_manager-0.2.0.dev0/cyclo_host_agent/__init__.py +19 -0
- cyclo_manager-0.2.0.dev0/cyclo_host_agent/main.py +57 -0
- cyclo_manager-0.2.0.dev0/cyclo_host_agent/models.py +96 -0
- cyclo_manager-0.2.0.dev0/cyclo_host_agent/routers/__init__.py +19 -0
- cyclo_manager-0.2.0.dev0/cyclo_host_agent/routers/repos.py +359 -0
- cyclo_manager-0.2.0.dev0/cyclo_host_agent/routers/update.py +175 -0
- {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager.egg-info/PKG-INFO +4 -1
- {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager.egg-info/SOURCES.txt +6 -0
- cyclo_manager-0.2.0.dev0/cyclo_manager.egg-info/entry_points.txt +4 -0
- cyclo_manager-0.2.0.dev0/cyclo_manager.egg-info/requires.txt +7 -0
- {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager.egg-info/top_level.txt +1 -0
- {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager_cli/__init__.py +1 -1
- cyclo_manager-0.2.0.dev0/cyclo_manager_cli/cli.py +274 -0
- {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager_cli/docker/docker-compose.yml +4 -2
- {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/pyproject.toml +9 -3
- {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/setup.cfg +1 -1
- cyclo_manager-0.1.1.dev0/cyclo_manager.egg-info/entry_points.txt +0 -2
- cyclo_manager-0.1.1.dev0/cyclo_manager.egg-info/requires.txt +0 -4
- cyclo_manager-0.1.1.dev0/cyclo_manager_cli/cli.py +0 -189
- {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/README.md +0 -0
- {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager.egg-info/dependency_links.txt +0 -0
- {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager_cli/config/config.yml +0 -0
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cyclo-manager
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0.dev0
|
|
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
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: fastapi>=0.100.0
|
|
9
|
+
Requires-Dist: uvicorn[standard]>=0.23.0
|
|
10
|
+
Requires-Dist: psutil>=5.9.0
|
|
8
11
|
Provides-Extra: dev
|
|
9
12
|
Requires-Dist: pytest; extra == "dev"
|
|
10
13
|
Requires-Dist: ruff; extra == "dev"
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
"""cyclo_host_agent: host-side agent for system stats and repo management."""
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
"""Cyclo host agent: FastAPI server on Unix Domain Socket for host-level operations."""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import uvicorn
|
|
25
|
+
from fastapi import FastAPI
|
|
26
|
+
|
|
27
|
+
from cyclo_host_agent.routers import repos, update
|
|
28
|
+
|
|
29
|
+
logging.basicConfig(
|
|
30
|
+
level=logging.INFO,
|
|
31
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
32
|
+
)
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
SOCKET_PATH = '/var/run/robotis/agent_sockets/host/host_agent.sock'
|
|
36
|
+
|
|
37
|
+
app = FastAPI(
|
|
38
|
+
title='cyclo_host_agent',
|
|
39
|
+
description='Host agent for Cyclo Manager: repo management.',
|
|
40
|
+
version='0.2.0',
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
app.include_router(repos.router)
|
|
44
|
+
app.include_router(update.router)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> None:
|
|
48
|
+
"""Entry point for the host agent; called by systemd via ExecStart."""
|
|
49
|
+
socket_path = Path(SOCKET_PATH)
|
|
50
|
+
socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
socket_path.unlink(missing_ok=True)
|
|
52
|
+
logger.info('Starting on %s', socket_path)
|
|
53
|
+
uvicorn.run(app, uds=str(socket_path), log_level='info')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == '__main__':
|
|
57
|
+
main()
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
"""Pydantic models for cyclo_host_agent API."""
|
|
20
|
+
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RepoInfo(BaseModel):
|
|
27
|
+
"""Single git repository info."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
path: str
|
|
31
|
+
branch: Optional[str] = None
|
|
32
|
+
remote: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RepoListResponse(BaseModel):
|
|
36
|
+
"""Response for GET /repos."""
|
|
37
|
+
|
|
38
|
+
repos: list[RepoInfo]
|
|
39
|
+
workspace_path: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RepoUpdateStatus(BaseModel):
|
|
43
|
+
"""Version update status for a single repository."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
current_version: Optional[str] = None
|
|
47
|
+
latest_version: Optional[str] = None
|
|
48
|
+
has_update: bool
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RepoUpdatesResponse(BaseModel):
|
|
52
|
+
"""Response for GET /repos/updates."""
|
|
53
|
+
|
|
54
|
+
repos: list[RepoUpdateStatus]
|
|
55
|
+
workspace_path: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FileChange(BaseModel):
|
|
59
|
+
"""A single entry from git status --porcelain."""
|
|
60
|
+
|
|
61
|
+
path: str
|
|
62
|
+
status: str # e.g. M, D, ??, etc.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RepoStatusResponse(BaseModel):
|
|
66
|
+
"""Response for GET /repos/{name}/status."""
|
|
67
|
+
|
|
68
|
+
name: str
|
|
69
|
+
changes: list[FileChange]
|
|
70
|
+
has_changes: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class UpdateRequest(BaseModel):
|
|
74
|
+
"""Request body for POST /repos/{name}/update."""
|
|
75
|
+
|
|
76
|
+
strategy: str # 'stash' | 'reset'
|
|
77
|
+
preserve_files: list[str] = [] # files to keep when using the reset strategy
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class UpdateResponse(BaseModel):
|
|
81
|
+
"""Response for POST /repos/{name}/update."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
success: bool
|
|
85
|
+
output: str
|
|
86
|
+
stash_conflict: bool = False
|
|
87
|
+
stash_conflict_files: list[str] = []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ContainerScriptResponse(BaseModel):
|
|
91
|
+
"""Response for POST /repos/{name}/container/{action}."""
|
|
92
|
+
|
|
93
|
+
name: str
|
|
94
|
+
action: str
|
|
95
|
+
success: bool
|
|
96
|
+
output: str
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
"""Routers package for cyclo_host_agent."""
|
|
@@ -0,0 +1,359 @@
|
|
|
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
|
+
"""Repository management endpoints."""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import urllib.request
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
from fastapi import APIRouter, HTTPException, status
|
|
30
|
+
|
|
31
|
+
from cyclo_host_agent.models import (
|
|
32
|
+
ContainerScriptResponse,
|
|
33
|
+
FileChange,
|
|
34
|
+
RepoInfo,
|
|
35
|
+
RepoListResponse,
|
|
36
|
+
RepoStatusResponse,
|
|
37
|
+
RepoUpdateStatus,
|
|
38
|
+
RepoUpdatesResponse,
|
|
39
|
+
UpdateRequest,
|
|
40
|
+
UpdateResponse,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
router = APIRouter(prefix='/repos', tags=['repos'])
|
|
44
|
+
|
|
45
|
+
GIT_TIMEOUT = 120.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_home() -> Path:
|
|
49
|
+
sudo_user = os.environ.get('SUDO_USER')
|
|
50
|
+
if sudo_user:
|
|
51
|
+
return Path(f'/home/{sudo_user}')
|
|
52
|
+
return Path.home()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
WORKSPACE_PATH = _resolve_home()
|
|
56
|
+
MANAGED_GITHUB_ORG = 'ROBOTIS-GIT'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── shared helpers ─────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def _get_repo(name: str) -> Path:
|
|
62
|
+
"""Return the repo path, raising HTTPException if it does not exist or is not a git repo."""
|
|
63
|
+
repo_path = WORKSPACE_PATH / name
|
|
64
|
+
if not repo_path.is_dir():
|
|
65
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
|
66
|
+
detail=f"Repo '{name}' not found")
|
|
67
|
+
if not (repo_path / '.git').exists():
|
|
68
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
|
69
|
+
detail=f"'{name}' is not a git repository")
|
|
70
|
+
return repo_path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _scan_repo_paths() -> list[Path]:
|
|
74
|
+
"""Return all directories under WORKSPACE_PATH that contain a .git folder."""
|
|
75
|
+
if not WORKSPACE_PATH.exists():
|
|
76
|
+
return []
|
|
77
|
+
return [
|
|
78
|
+
item for item in sorted(WORKSPACE_PATH.iterdir())
|
|
79
|
+
if item.is_dir() and (item / '.git').exists()
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _fmt_cmd(cmd: str, output: str) -> str:
|
|
84
|
+
return f'$ {cmd}\n{output.strip()}' if output.strip() else f'$ {cmd}'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── git helpers ────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
async def _git(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]:
|
|
90
|
+
proc = await asyncio.create_subprocess_exec(
|
|
91
|
+
'git', *args,
|
|
92
|
+
cwd=str(cwd) if cwd else None,
|
|
93
|
+
stdout=asyncio.subprocess.PIPE,
|
|
94
|
+
stderr=asyncio.subprocess.PIPE,
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=GIT_TIMEOUT)
|
|
98
|
+
except asyncio.TimeoutError:
|
|
99
|
+
proc.kill()
|
|
100
|
+
await proc.wait()
|
|
101
|
+
raise
|
|
102
|
+
return proc.returncode, stdout.decode(), stderr.decode()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _repo_info(repo_path: Path) -> RepoInfo:
|
|
106
|
+
branch: Optional[str] = None
|
|
107
|
+
remote: Optional[str] = None
|
|
108
|
+
try:
|
|
109
|
+
rc, out, _ = await _git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd=repo_path)
|
|
110
|
+
if rc == 0:
|
|
111
|
+
branch = out.strip()
|
|
112
|
+
rc, out, _ = await _git(['remote', 'get-url', 'origin'], cwd=repo_path)
|
|
113
|
+
if rc == 0:
|
|
114
|
+
remote = out.strip()
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
return RepoInfo(name=repo_path.name, path=str(repo_path), branch=branch, remote=remote)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── version check helpers ──────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
def _parse_version(version_str: str) -> tuple[int, ...]:
|
|
123
|
+
parts = []
|
|
124
|
+
for p in (version_str or '').strip().lstrip('v').split('.'):
|
|
125
|
+
try:
|
|
126
|
+
parts.append(int(p))
|
|
127
|
+
except ValueError:
|
|
128
|
+
parts.append(0)
|
|
129
|
+
return tuple(parts) if parts else (0,)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_newer(latest: str, current: str) -> bool:
|
|
133
|
+
return _parse_version(latest) > _parse_version(current)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_github_slug(remote_url: str) -> Optional[str]:
|
|
137
|
+
https_match = re.match(r'https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$', remote_url)
|
|
138
|
+
if https_match:
|
|
139
|
+
return https_match.group(1)
|
|
140
|
+
ssh_match = re.match(r'git@github\.com:([^/]+/[^/]+?)(?:\.git)?$', remote_url)
|
|
141
|
+
if ssh_match:
|
|
142
|
+
return ssh_match.group(1)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_managed_remote(remote_url: str) -> bool:
|
|
147
|
+
slug = _parse_github_slug(remote_url)
|
|
148
|
+
if not slug:
|
|
149
|
+
return False
|
|
150
|
+
return slug.split('/')[0].lower() == MANAGED_GITHUB_ORG.lower()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _read_package_xml_version(repo_path: Path) -> Optional[str]:
|
|
154
|
+
candidates = [repo_path / 'package.xml'] + sorted(repo_path.glob('*/package.xml'))
|
|
155
|
+
for candidate in candidates:
|
|
156
|
+
if candidate.is_file():
|
|
157
|
+
try:
|
|
158
|
+
text = candidate.read_text(errors='replace')
|
|
159
|
+
m = re.search(r'<version>\s*([^<]+)\s*</version>', text)
|
|
160
|
+
if m:
|
|
161
|
+
return m.group(1).strip()
|
|
162
|
+
except OSError:
|
|
163
|
+
continue
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _fetch_github_latest(slug: str) -> Optional[str]:
|
|
168
|
+
url = f'https://api.github.com/repos/{slug}/releases/latest'
|
|
169
|
+
req = urllib.request.Request(url, headers={'User-Agent': 'cyclo-manager/1.0'})
|
|
170
|
+
try:
|
|
171
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
172
|
+
data = json.loads(resp.read().decode())
|
|
173
|
+
return (data.get('tag_name') or '').strip() or None
|
|
174
|
+
except Exception:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _repo_update_status(repo_path: Path) -> Optional[RepoUpdateStatus]:
|
|
179
|
+
rc, remote_url, _ = await _git(['remote', 'get-url', 'origin'], cwd=repo_path)
|
|
180
|
+
remote_url = remote_url.strip() if rc == 0 else ''
|
|
181
|
+
|
|
182
|
+
if not remote_url or not _is_managed_remote(remote_url):
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
slug = _parse_github_slug(remote_url)
|
|
186
|
+
current = _read_package_xml_version(repo_path)
|
|
187
|
+
if not current:
|
|
188
|
+
return RepoUpdateStatus(name=repo_path.name, has_update=False)
|
|
189
|
+
|
|
190
|
+
latest = await asyncio.to_thread(_fetch_github_latest, slug)
|
|
191
|
+
if not latest:
|
|
192
|
+
return RepoUpdateStatus(name=repo_path.name, current_version=current, has_update=False)
|
|
193
|
+
|
|
194
|
+
return RepoUpdateStatus(
|
|
195
|
+
name=repo_path.name,
|
|
196
|
+
current_version=current,
|
|
197
|
+
latest_version=latest,
|
|
198
|
+
has_update=_is_newer(latest, current),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── update strategy helpers ────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
async def _update_stash(repo_path: Path) -> UpdateResponse:
|
|
205
|
+
name = repo_path.name
|
|
206
|
+
lines: list[str] = []
|
|
207
|
+
|
|
208
|
+
rc, out, err = await _git(['stash', '-u'], cwd=repo_path)
|
|
209
|
+
lines.append(_fmt_cmd('git stash -u', out + err))
|
|
210
|
+
if rc != 0:
|
|
211
|
+
return UpdateResponse(name=name, success=False, output='\n'.join(lines))
|
|
212
|
+
|
|
213
|
+
rc, out, err = await _git(['pull'], cwd=repo_path)
|
|
214
|
+
lines.append(_fmt_cmd('git pull', out + err))
|
|
215
|
+
if rc != 0:
|
|
216
|
+
await _git(['stash', 'pop'], cwd=repo_path)
|
|
217
|
+
return UpdateResponse(name=name, success=False, output='\n'.join(lines))
|
|
218
|
+
|
|
219
|
+
rc, out, err = await _git(['stash', 'pop'], cwd=repo_path)
|
|
220
|
+
lines.append(_fmt_cmd('git stash pop', out + err))
|
|
221
|
+
|
|
222
|
+
stash_conflict = False
|
|
223
|
+
stash_conflict_files: list[str] = []
|
|
224
|
+
if rc != 0:
|
|
225
|
+
_, conflict_out, _ = await _git(
|
|
226
|
+
['diff', '--name-only', '--diff-filter=U'], cwd=repo_path
|
|
227
|
+
)
|
|
228
|
+
stash_conflict_files = [f for f in conflict_out.splitlines() if f.strip()]
|
|
229
|
+
await _git(['checkout', 'stash@{0}', '--', '.'], cwd=repo_path)
|
|
230
|
+
await _git(['reset', 'HEAD', '.'], cwd=repo_path)
|
|
231
|
+
await _git(['stash', 'drop'], cwd=repo_path)
|
|
232
|
+
stash_conflict = True
|
|
233
|
+
lines.append(
|
|
234
|
+
'Conflict detected — local changes preserved'
|
|
235
|
+
+ (f': {", ".join(stash_conflict_files)}' if stash_conflict_files else '')
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return UpdateResponse(
|
|
239
|
+
name=name, success=True, output='\n'.join(lines),
|
|
240
|
+
stash_conflict=stash_conflict, stash_conflict_files=stash_conflict_files,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def _update_reset(repo_path: Path, preserve_files: list[str]) -> UpdateResponse:
|
|
245
|
+
name = repo_path.name
|
|
246
|
+
lines: list[str] = []
|
|
247
|
+
|
|
248
|
+
backups: dict[str, bytes] = {}
|
|
249
|
+
for rel in preserve_files:
|
|
250
|
+
full = repo_path / rel
|
|
251
|
+
if full.is_file():
|
|
252
|
+
try:
|
|
253
|
+
backups[rel] = full.read_bytes()
|
|
254
|
+
except OSError:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
rc, out, err = await _git(['reset', '--hard', 'HEAD'], cwd=repo_path)
|
|
258
|
+
lines.append(_fmt_cmd('git reset --hard HEAD', out + err))
|
|
259
|
+
|
|
260
|
+
rc, out, err = await _git(['clean', '-fd'], cwd=repo_path)
|
|
261
|
+
lines.append(_fmt_cmd('git clean -fd', out + err))
|
|
262
|
+
|
|
263
|
+
rc, out, err = await _git(['pull'], cwd=repo_path)
|
|
264
|
+
lines.append(_fmt_cmd('git pull', out + err))
|
|
265
|
+
if rc != 0:
|
|
266
|
+
return UpdateResponse(name=name, success=False, output='\n'.join(lines))
|
|
267
|
+
|
|
268
|
+
for rel, content in backups.items():
|
|
269
|
+
full = repo_path / rel
|
|
270
|
+
try:
|
|
271
|
+
full.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
full.write_bytes(content)
|
|
273
|
+
lines.append(f'Restored: {rel}')
|
|
274
|
+
except OSError as e:
|
|
275
|
+
lines.append(f'Restore failed: {rel} ({e})')
|
|
276
|
+
|
|
277
|
+
return UpdateResponse(name=name, success=True, output='\n'.join(lines))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── container helper ───────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
|
|
283
|
+
script = repo_path / 'docker' / 'container.sh'
|
|
284
|
+
if not script.exists():
|
|
285
|
+
return False, f'container.sh not found at {script}'
|
|
286
|
+
proc = await asyncio.create_subprocess_exec(
|
|
287
|
+
'bash', str(script), action,
|
|
288
|
+
cwd=str(repo_path / 'docker'),
|
|
289
|
+
stdin=asyncio.subprocess.PIPE,
|
|
290
|
+
stdout=asyncio.subprocess.PIPE,
|
|
291
|
+
stderr=asyncio.subprocess.PIPE,
|
|
292
|
+
)
|
|
293
|
+
try:
|
|
294
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(input=b'y\n'), timeout=300.0)
|
|
295
|
+
except asyncio.TimeoutError:
|
|
296
|
+
proc.kill()
|
|
297
|
+
await proc.wait()
|
|
298
|
+
return False, 'Timeout waiting for container.sh'
|
|
299
|
+
return proc.returncode == 0, (stdout.decode() + stderr.decode()).strip()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ── endpoints ──────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
@router.get('/updates', response_model=RepoUpdatesResponse)
|
|
305
|
+
async def get_repo_updates() -> RepoUpdatesResponse:
|
|
306
|
+
repo_paths = _scan_repo_paths()
|
|
307
|
+
raw = await asyncio.gather(*(_repo_update_status(p) for p in repo_paths))
|
|
308
|
+
statuses = [s for s in raw if s is not None]
|
|
309
|
+
return RepoUpdatesResponse(repos=statuses, workspace_path=str(WORKSPACE_PATH))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@router.get('', response_model=RepoListResponse)
|
|
313
|
+
async def list_repos() -> RepoListResponse:
|
|
314
|
+
candidates = _scan_repo_paths()
|
|
315
|
+
infos = await asyncio.gather(*(_repo_info(item) for item in candidates))
|
|
316
|
+
repos = [r for r in infos if r.remote and _is_managed_remote(r.remote)]
|
|
317
|
+
return RepoListResponse(repos=repos, workspace_path=str(WORKSPACE_PATH))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@router.get('/{name}/status', response_model=RepoStatusResponse)
|
|
321
|
+
async def get_repo_status(name: str) -> RepoStatusResponse:
|
|
322
|
+
repo_path = _get_repo(name)
|
|
323
|
+
rc, out, _ = await _git(['status', '--porcelain'], cwd=repo_path)
|
|
324
|
+
changes: list[FileChange] = []
|
|
325
|
+
for line in out.splitlines():
|
|
326
|
+
if not line.strip():
|
|
327
|
+
continue
|
|
328
|
+
code = line[0:2].rstrip()
|
|
329
|
+
path = line[3:].strip()
|
|
330
|
+
if ' -> ' in path:
|
|
331
|
+
path = path.split(' -> ', 1)[1]
|
|
332
|
+
changes.append(FileChange(path=path, status=code))
|
|
333
|
+
return RepoStatusResponse(name=name, changes=changes, has_changes=bool(changes))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@router.post('/{name}/container/stop', response_model=ContainerScriptResponse)
|
|
337
|
+
async def stop_repo_container(name: str) -> ContainerScriptResponse:
|
|
338
|
+
repo_path = _get_repo(name)
|
|
339
|
+
success, output = await _run_container_sh(repo_path, 'stop')
|
|
340
|
+
return ContainerScriptResponse(name=name, action='stop', success=success, output=output)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@router.post('/{name}/container/start', response_model=ContainerScriptResponse)
|
|
344
|
+
async def start_repo_container(name: str) -> ContainerScriptResponse:
|
|
345
|
+
repo_path = _get_repo(name)
|
|
346
|
+
success, output = await _run_container_sh(repo_path, 'start')
|
|
347
|
+
return ContainerScriptResponse(name=name, action='start', success=success, output=output)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@router.post('/{name}/update', response_model=UpdateResponse)
|
|
351
|
+
async def update_repo(name: str, req: UpdateRequest) -> UpdateResponse:
|
|
352
|
+
repo_path = _get_repo(name)
|
|
353
|
+
try:
|
|
354
|
+
if req.strategy == 'reset':
|
|
355
|
+
return await _update_reset(repo_path, req.preserve_files)
|
|
356
|
+
return await _update_stash(repo_path)
|
|
357
|
+
except asyncio.TimeoutError:
|
|
358
|
+
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
359
|
+
detail='git operation timed out')
|